diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 378d0ebec..000000000 --- a/.claude/settings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index ca3a44cbd..000000000 --- a/.cursor/rules/00_repo_tooling.mdc +++ /dev/null @@ -1,24 +0,0 @@ ---- -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 deleted file mode 100644 index 72ff65979..000000000 --- a/.cursor/rules/05_permissions_and_tools.mdc +++ /dev/null @@ -1,18 +0,0 @@ ---- -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 deleted file mode 100644 index 230f14569..000000000 --- a/.cursor/rules/10_architecture.mdc +++ /dev/null @@ -1,30 +0,0 @@ ---- -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 deleted file mode 100644 index 0bd03880d..000000000 --- a/.cursor/rules/20_codegen_boundaries.mdc +++ /dev/null @@ -1,11 +0,0 @@ ---- -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 deleted file mode 100644 index f39442341..000000000 --- a/.cursor/rules/30_cli_and_commands.mdc +++ /dev/null @@ -1,18 +0,0 @@ ---- -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 deleted file mode 100644 index 7f1053915..000000000 --- a/.cursor/rules/40_temporal_and_agents.mdc +++ /dev/null @@ -1,17 +0,0 @@ ---- -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 deleted file mode 100644 index 420de4ac7..000000000 --- a/.cursor/rules/50_tests_and_mocking.mdc +++ /dev/null @@ -1,16 +0,0 @@ ---- -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 deleted file mode 100644 index f36f02d7c..000000000 --- a/.cursor/rules/60_style_lint_typecheck.mdc +++ /dev/null @@ -1,16 +0,0 @@ ---- -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 deleted file mode 100644 index 7d16e9d01..000000000 --- a/.cursor/rules/70_examples_and_docs.mdc +++ /dev/null @@ -1,11 +0,0 @@ ---- -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 index a7eb0f23b..62c2d13f5 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,8 +1,8 @@ -ARG VARIANT="3.12" +ARG VARIANT="3.9" FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} USER vscode -COPY --from=ghcr.io/astral-sh/uv:0.10.2 /uv /uvx /bin/ +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ RUN echo "[[ -d .venv ]] && source .venv/bin/activate || export PATH=\$PATH" >> /home/vscode/.bashrc diff --git a/.github/scripts/sync_agents.py b/.github/scripts/sync_agents.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/.github/workflows/agentex-tutorials-test.yml b/.github/workflows/agentex-tutorials-test.yml deleted file mode 100644 index 7f2c762dd..000000000 --- a/.github/workflows/agentex-tutorials-test.yml +++ /dev/null @@ -1,333 +0,0 @@ -name: Test Tutorial Agents - -on: - pull_request: - branches: [main, next] - push: - branches: [main] - workflow_dispatch: - -jobs: - find-tutorials: - 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 - - - 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 AgentEx SDK wheel..." - uv build - echo "โœ… SDK built successfully" - ls -la dist/ - - - name: Test Tutorial - id: run-test - working-directory: ./examples/tutorials - env: - OPENAI_API_KEY: ${{ secrets.TUTORIAL_OPENAI_API_KEY }} - 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() - 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/build-and-push-tutorial-agent.yml b/.github/workflows/build-and-push-tutorial-agent.yml deleted file mode 100644 index 1b9cda3eb..000000000 --- a/.github/workflows/build-and-push-tutorial-agent.yml +++ /dev/null @@ -1,358 +0,0 @@ -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: - 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 push based on event type - if [ "${{ github.event_name }}" = "push" ] || [ "${{ inputs.rebuild_all }}" = "true" ]; then - SHOULD_PUSH=true - VERSION_TAG="latest" - echo "๐Ÿš€ Building agent (will push after validation): ${{ matrix.agent_path }}" - else - SHOULD_PUSH=false - VERSION_TAG="${{ github.sha }}" - echo "๐Ÿ” Building agent for validation: ${{ matrix.agent_path }}" - # Set full image name for validation step (local build) - echo "FULL_IMAGE=${REGISTRY}/${REPOSITORY_NAME}:${VERSION_TAG}" >> $GITHUB_ENV - # Skip image validation for PRs since Buildx doesn't load multi-platform images locally - echo "SKIP_VALIDATION=true" >> $GITHUB_ENV - fi - - # Always build locally first (without push) - BUILD_ARGS="--manifest ${{ matrix.agent_path }}/manifest.yaml --registry ${REGISTRY} --tag ${VERSION_TAG} --platforms linux/amd64,linux/arm64 --repository-name ${REPOSITORY_NAME}" - - 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 "SHOULD_PUSH=${SHOULD_PUSH}" >> $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: Push Agent Image - if: env.SHOULD_PUSH == 'true' - run: | - FULL_IMAGE="${{ env.FULL_IMAGE }}" - echo "๐Ÿš€ Pushing validated image: $FULL_IMAGE" - docker push "$FULL_IMAGE" - echo "โœ… Successfully pushed: $FULL_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/lint-pr.yaml b/.github/workflows/lint-pr.yaml deleted file mode 100644 index dc165a271..000000000 --- a/.github/workflows/lint-pr.yaml +++ /dev/null @@ -1,144 +0,0 @@ -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 index 7c154bbd3..a9ce9049e 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -19,7 +19,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: - version: '0.10.2' + version: '0.9.13' - name: Publish to PyPI run: | diff --git a/.gitignore b/.gitignore index 8d1280062..3824f4c48 100644 --- a/.gitignore +++ b/.gitignore @@ -14,8 +14,3 @@ dist .envrc codegen.log Brewfile.lock.json - -.DS_Store - -# Claude workspace directories -.claude-workspace/ \ No newline at end of file diff --git a/.python-version b/.python-version index e4fba2183..43077b246 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.12 +3.9.18 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 8032c17e8..a71305534 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { ".": "0.12.0" -} +} \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 3c1b3e0e6..5e76faaa3 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 64 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-46769b729d89151fb7e7ae15725678af99f55ef32d283e34a1e143057aa87b23.yml -openapi_spec_hash: 9115c9f283257e0636aba67fadfeda0a +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-634c3b691c4bb1495260c8daa07b86d9945ef00284ef385f1559850cc939ee63.yml +openapi_spec_hash: a7af5fa29fb031a17a025c2dd95a4dba config_hash: 82cb83ac175dbf40265128506294218b diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 2d735cafe..000000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "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/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 7e27e7132..000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,1014 +0,0 @@ -# Changelog - -## Unreleased - -### 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.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 deleted file mode 100644 index 7f62a42fd..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,120 +0,0 @@ -# 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/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 - - `/types/` - Custom type definitions - - `acp.py`, `json_rpc.py` - **back-compat shims** re-exporting from - `agentex.protocol.*`. Existing `from agentex.lib.types.{acp,json_rpc} - import ...` keeps working; new code should import from the canonical - `agentex.protocol.*` paths. - - Other modules (`tracing`, `agent_card`, `credentials`, `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 index e8e6e8813..eeebfaa17 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,39 +32,6 @@ Alternatively if you don't want to install `uv`, you can stick with the standard $ 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 @@ -152,8 +119,3 @@ You can release to package managers by using [the `Publish PyPI` GitHub action]( 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/README.md b/README.md index 5941deffa..44d302aac 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ - # Agentex Python API library @@ -68,37 +67,6 @@ 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. diff --git a/examples/demos/procurement_agent/.dockerignore b/examples/demos/procurement_agent/.dockerignore deleted file mode 100644 index c4f7a8b4b..000000000 --- a/examples/demos/procurement_agent/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 92316bc3d..000000000 --- a/examples/demos/procurement_agent/.gitignore +++ /dev/null @@ -1,62 +0,0 @@ -# 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 deleted file mode 100644 index 17dd0e680..000000000 --- a/examples/demos/procurement_agent/Dockerfile +++ /dev/null @@ -1,48 +0,0 @@ -# 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 deleted file mode 100644 index 878c2ec31..000000000 --- a/examples/demos/procurement_agent/README.md +++ /dev/null @@ -1,412 +0,0 @@ -# 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 deleted file mode 100644 index 53b70d152..000000000 --- a/examples/demos/procurement_agent/dev.ipynb +++ /dev/null @@ -1,126 +0,0 @@ -{ - "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 deleted file mode 100644 index 90f44ae6c..000000000 --- a/examples/demos/procurement_agent/environments.yaml +++ /dev/null @@ -1,64 +0,0 @@ -# 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 deleted file mode 100644 index ddb96573c..000000000 --- a/examples/demos/procurement_agent/evals/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# 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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/demos/procurement_agent/evals/conftest.py b/examples/demos/procurement_agent/evals/conftest.py deleted file mode 100644 index 28e299b45..000000000 --- a/examples/demos/procurement_agent/evals/conftest.py +++ /dev/null @@ -1,227 +0,0 @@ -""" -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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/demos/procurement_agent/evals/fixtures/events.py b/examples/demos/procurement_agent/evals/fixtures/events.py deleted file mode 100644 index 31f1c919b..000000000 --- a/examples/demos/procurement_agent/evals/fixtures/events.py +++ /dev/null @@ -1,108 +0,0 @@ -""" -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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/demos/procurement_agent/evals/graders/database.py b/examples/demos/procurement_agent/evals/graders/database.py deleted file mode 100644 index e1651662c..000000000 --- a/examples/demos/procurement_agent/evals/graders/database.py +++ /dev/null @@ -1,187 +0,0 @@ -""" -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 deleted file mode 100644 index 7c28a8626..000000000 --- a/examples/demos/procurement_agent/evals/graders/tool_calls.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -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 deleted file mode 100644 index 71d66ba7f..000000000 --- a/examples/demos/procurement_agent/evals/pytest.ini +++ /dev/null @@ -1,8 +0,0 @@ -[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 deleted file mode 100644 index 8b3b6ea04..000000000 --- a/examples/demos/procurement_agent/evals/report.html +++ /dev/null @@ -1,1094 +0,0 @@ - - - - - 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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/demos/procurement_agent/evals/tasks/test_inspection_failed.py b/examples/demos/procurement_agent/evals/tasks/test_inspection_failed.py deleted file mode 100644 index a975ecd5d..000000000 --- a/examples/demos/procurement_agent/evals/tasks/test_inspection_failed.py +++ /dev/null @@ -1,162 +0,0 @@ -""" -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 deleted file mode 100644 index 815a5ab23..000000000 --- a/examples/demos/procurement_agent/evals/tasks/test_inspection_passed.py +++ /dev/null @@ -1,116 +0,0 @@ -""" -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 deleted file mode 100644 index 9d9ee293f..000000000 --- a/examples/demos/procurement_agent/evals/tasks/test_shipment_arrived.py +++ /dev/null @@ -1,119 +0,0 @@ -""" -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 deleted file mode 100644 index 4e64172e7..000000000 --- a/examples/demos/procurement_agent/evals/tasks/test_shipment_departed.py +++ /dev/null @@ -1,202 +0,0 @@ -""" -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 deleted file mode 100644 index 3182eab7e..000000000 --- a/examples/demos/procurement_agent/evals/tasks/test_submittal_approved.py +++ /dev/null @@ -1,87 +0,0 @@ -""" -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 deleted file mode 100644 index 823a1cd72..000000000 --- a/examples/demos/procurement_agent/manifest.yaml +++ /dev/null @@ -1,145 +0,0 @@ -# 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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/demos/procurement_agent/project/acp.py b/examples/demos/procurement_agent/project/acp.py deleted file mode 100644 index 54cac94a2..000000000 --- a/examples/demos/procurement_agent/project/acp.py +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 8c8e7bd57..000000000 --- a/examples/demos/procurement_agent/project/activities/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Procurement agent activities module.""" diff --git a/examples/demos/procurement_agent/project/activities/activities.py b/examples/demos/procurement_agent/project/activities/activities.py deleted file mode 100644 index f2581e140..000000000 --- a/examples/demos/procurement_agent/project/activities/activities.py +++ /dev/null @@ -1,570 +0,0 @@ -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 deleted file mode 100644 index 08d7078bc..000000000 --- a/examples/demos/procurement_agent/project/agents/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""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 deleted file mode 100644 index ca6ea6809..000000000 --- a/examples/demos/procurement_agent/project/agents/extract_learnings_agent.py +++ /dev/null @@ -1,53 +0,0 @@ -"""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 deleted file mode 100644 index 45858d45e..000000000 --- a/examples/demos/procurement_agent/project/agents/procurement_agent.py +++ /dev/null @@ -1,515 +0,0 @@ -"""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 deleted file mode 100644 index e74f2d46f..000000000 --- a/examples/demos/procurement_agent/project/agents/summarization_agent.py +++ /dev/null @@ -1,53 +0,0 @@ -"""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 deleted file mode 100644 index ec504c844..000000000 --- a/examples/demos/procurement_agent/project/data/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Procurement agent data module.""" diff --git a/examples/demos/procurement_agent/project/data/database.py b/examples/demos/procurement_agent/project/data/database.py deleted file mode 100644 index a756f7efa..000000000 --- a/examples/demos/procurement_agent/project/data/database.py +++ /dev/null @@ -1,686 +0,0 @@ -""" -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 deleted file mode 100644 index 1b2da8d1c..000000000 --- a/examples/demos/procurement_agent/project/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Procurement agent models module.""" diff --git a/examples/demos/procurement_agent/project/models/events.py b/examples/demos/procurement_agent/project/models/events.py deleted file mode 100644 index 634626ec6..000000000 --- a/examples/demos/procurement_agent/project/models/events.py +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 127a810ff..000000000 --- a/examples/demos/procurement_agent/project/run_worker.py +++ /dev/null @@ -1,96 +0,0 @@ -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 deleted file mode 100644 index 6f84b9de5..000000000 --- a/examples/demos/procurement_agent/project/scripts/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""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 deleted file mode 100644 index 44ed6247c..000000000 --- a/examples/demos/procurement_agent/project/scripts/happy_path.py +++ /dev/null @@ -1,184 +0,0 @@ -#!/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 deleted file mode 100644 index c2e2ebc53..000000000 --- a/examples/demos/procurement_agent/project/scripts/human_in_the_loop.py +++ /dev/null @@ -1,157 +0,0 @@ -#!/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 deleted file mode 100644 index 164c4a9e5..000000000 --- a/examples/demos/procurement_agent/project/scripts/out_of_order.py +++ /dev/null @@ -1,180 +0,0 @@ -#!/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 deleted file mode 100644 index e85b75c4d..000000000 --- a/examples/demos/procurement_agent/project/scripts/send_test_events.py +++ /dev/null @@ -1,295 +0,0 @@ -#!/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 deleted file mode 100644 index cab515374..000000000 --- a/examples/demos/procurement_agent/project/scripts/send_test_events_lite.py +++ /dev/null @@ -1,192 +0,0 @@ -#!/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 deleted file mode 100644 index be8d6ac87..000000000 --- a/examples/demos/procurement_agent/project/utils/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""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 deleted file mode 100644 index e6cb61b3a..000000000 --- a/examples/demos/procurement_agent/project/utils/learning_extraction.py +++ /dev/null @@ -1,69 +0,0 @@ -"""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 deleted file mode 100644 index b74ad1e37..000000000 --- a/examples/demos/procurement_agent/project/utils/summarization.py +++ /dev/null @@ -1,205 +0,0 @@ -""" -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 deleted file mode 100644 index 115c2d2f6..000000000 --- a/examples/demos/procurement_agent/project/workflow.py +++ /dev/null @@ -1,454 +0,0 @@ -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 deleted file mode 100644 index 555819a5d..000000000 --- a/examples/demos/procurement_agent/pyproject.toml +++ /dev/null @@ -1,37 +0,0 @@ -[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 deleted file mode 100755 index 024d9ac17..000000000 --- a/examples/launch-tutorials.sh +++ /dev/null @@ -1,341 +0,0 @@ -#!/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 deleted file mode 100644 index c2d7fca4d..000000000 --- a/examples/tutorials/00_sync/000_hello_acp/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index b91d13397..000000000 --- a/examples/tutorials/00_sync/000_hello_acp/Dockerfile +++ /dev/null @@ -1,51 +0,0 @@ -# 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 deleted file mode 100644 index b007cc56f..000000000 --- a/examples/tutorials/00_sync/000_hello_acp/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# [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 deleted file mode 100644 index a50a29f35..000000000 --- a/examples/tutorials/00_sync/000_hello_acp/dev.ipynb +++ /dev/null @@ -1,158 +0,0 @@ -{ - "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 deleted file mode 100644 index 37214b06e..000000000 --- a/examples/tutorials/00_sync/000_hello_acp/manifest.yaml +++ /dev/null @@ -1,120 +0,0 @@ -# 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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/00_sync/000_hello_acp/project/acp.py b/examples/tutorials/00_sync/000_hello_acp/project/acp.py deleted file mode 100644 index 63346574b..000000000 --- a/examples/tutorials/00_sync/000_hello_acp/project/acp.py +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 71110739a..000000000 --- a/examples/tutorials/00_sync/000_hello_acp/pyproject.toml +++ /dev/null @@ -1,36 +0,0 @@ -[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 deleted file mode 100644 index ad82771f6..000000000 --- a/examples/tutorials/00_sync/000_hello_acp/tests/test_agent.py +++ /dev/null @@ -1,129 +0,0 @@ -""" -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 deleted file mode 100644 index c2d7fca4d..000000000 --- a/examples/tutorials/00_sync/010_multiturn/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index d82cf5775..000000000 --- a/examples/tutorials/00_sync/010_multiturn/.ipynb_checkpoints/dev-checkpoint.ipynb +++ /dev/null @@ -1,166 +0,0 @@ -{ - "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 deleted file mode 100644 index 71ccbaf53..000000000 --- a/examples/tutorials/00_sync/010_multiturn/Dockerfile +++ /dev/null @@ -1,51 +0,0 @@ -# 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 deleted file mode 100644 index 6f585cbbe..000000000 --- a/examples/tutorials/00_sync/010_multiturn/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# [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 deleted file mode 100644 index c7c50532f..000000000 --- a/examples/tutorials/00_sync/010_multiturn/dev.ipynb +++ /dev/null @@ -1,166 +0,0 @@ -{ - "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 deleted file mode 100644 index c7e094aa6..000000000 --- a/examples/tutorials/00_sync/010_multiturn/manifest.yaml +++ /dev/null @@ -1,118 +0,0 @@ -# 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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/00_sync/010_multiturn/project/acp.py b/examples/tutorials/00_sync/010_multiturn/project/acp.py deleted file mode 100644 index b0d2098fb..000000000 --- a/examples/tutorials/00_sync/010_multiturn/project/acp.py +++ /dev/null @@ -1,110 +0,0 @@ -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 deleted file mode 100644 index d6ec48d20..000000000 --- a/examples/tutorials/00_sync/010_multiturn/pyproject.toml +++ /dev/null @@ -1,35 +0,0 @@ -[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 deleted file mode 100644 index 510e9159d..000000000 --- a/examples/tutorials/00_sync/010_multiturn/tests/test_agent.py +++ /dev/null @@ -1,172 +0,0 @@ -""" -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 deleted file mode 100644 index c2d7fca4d..000000000 --- a/examples/tutorials/00_sync/020_streaming/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 00137d7f9..000000000 --- a/examples/tutorials/00_sync/020_streaming/Dockerfile +++ /dev/null @@ -1,50 +0,0 @@ -# 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 deleted file mode 100644 index a4f6f4765..000000000 --- a/examples/tutorials/00_sync/020_streaming/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# [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 deleted file mode 100644 index b4e517c3f..000000000 --- a/examples/tutorials/00_sync/020_streaming/dev.ipynb +++ /dev/null @@ -1,158 +0,0 @@ -{ - "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 deleted file mode 100644 index 39a04d0f8..000000000 --- a/examples/tutorials/00_sync/020_streaming/manifest.yaml +++ /dev/null @@ -1,119 +0,0 @@ -# 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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/00_sync/020_streaming/project/acp.py b/examples/tutorials/00_sync/020_streaming/project/acp.py deleted file mode 100644 index 80d1cb8bd..000000000 --- a/examples/tutorials/00_sync/020_streaming/project/acp.py +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index b215db076..000000000 --- a/examples/tutorials/00_sync/020_streaming/pyproject.toml +++ /dev/null @@ -1,35 +0,0 @@ -[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 deleted file mode 100644 index b4ff65ff5..000000000 --- a/examples/tutorials/00_sync/020_streaming/tests/test_agent.py +++ /dev/null @@ -1,175 +0,0 @@ -""" -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 deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/00_sync/030_langgraph/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index ed7172f0d..000000000 --- a/examples/tutorials/00_sync/030_langgraph/Dockerfile +++ /dev/null @@ -1,50 +0,0 @@ -# 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 deleted file mode 100644 index e5b1db0f7..000000000 --- a/examples/tutorials/00_sync/030_langgraph/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Tutorial 030: Sync LangGraph Agent - -This tutorial demonstrates how to build 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 - -![Graph](graph.png) - -## Key Concepts - -### Sync ACP -The sync ACP model uses HTTP request/response for communication. The `@acp.on_message_send` handler receives a message and yields streaming events 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 -The agent streams tokens as they're generated using `convert_langgraph_to_agentex_events()`, which converts LangGraph's stream events into AgentEx `TaskMessageUpdate` events. - -## Files - -| File | Description | -|------|-------------| -| `project/acp.py` | ACP server and message handler | -| `project/graph.py` | LangGraph state graph definition | -| `project/tools.py` | Tool definitions (weather example) | -| `tests/test_agent.py` | Integration tests | -| `manifest.yaml` | Agent configuration | - -## Running Locally - -```bash -# From this directory -agentex agents run -``` - -## Running Tests - -```bash -pytest tests/test_agent.py -v -``` diff --git a/examples/tutorials/00_sync/030_langgraph/graph.png b/examples/tutorials/00_sync/030_langgraph/graph.png deleted file mode 100644 index 16d22a1e7..000000000 Binary files a/examples/tutorials/00_sync/030_langgraph/graph.png and /dev/null differ diff --git a/examples/tutorials/00_sync/030_langgraph/manifest.yaml b/examples/tutorials/00_sync/030_langgraph/manifest.yaml deleted file mode 100644 index bfe005626..000000000 --- a/examples/tutorials/00_sync/030_langgraph/manifest.yaml +++ /dev/null @@ -1,58 +0,0 @@ -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 with tool calling and streaming - - 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 with tool calling and streaming" - 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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/00_sync/030_langgraph/project/acp.py b/examples/tutorials/00_sync/030_langgraph/project/acp.py deleted file mode 100644 index 517a00322..000000000 --- a/examples/tutorials/00_sync/030_langgraph/project/acp.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -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 __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.adk import create_langgraph_tracing_handler, convert_langgraph_to_agentex_events -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.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.core.tracing.tracing_processor_manager import add_tracing_processor_config - -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 - user_message = params.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: - callback = create_langgraph_tracing_handler( - trace_id=thread_id, - parent_span_id=turn_span.id if turn_span else None, - ) - - stream = graph.astream( - {"messages": [{"role": "user", "content": user_message}]}, - config={ - "configurable": {"thread_id": thread_id}, - "callbacks": [callback], - }, - stream_mode=["messages", "updates"], - ) - - final_text = "" - async for event in convert_langgraph_to_agentex_events(stream): - # 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/examples/tutorials/00_sync/030_langgraph/project/graph.py b/examples/tutorials/00_sync/030_langgraph/project/graph.py deleted file mode 100644 index 53728cd58..000000000 --- a/examples/tutorials/00_sync/030_langgraph/project/graph.py +++ /dev/null @@ -1,73 +0,0 @@ -""" -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 __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. - - Returns: - A compiled LangGraph StateGraph ready for invocation. - """ - 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 deleted file mode 100644 index 1b402a906..000000000 --- a/examples/tutorials/00_sync/030_langgraph/project/tools.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -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/examples/tutorials/00_sync/030_langgraph/pyproject.toml b/examples/tutorials/00_sync/030_langgraph/pyproject.toml deleted file mode 100644 index fc9f99971..000000000 --- a/examples/tutorials/00_sync/030_langgraph/pyproject.toml +++ /dev/null @@ -1,37 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "s030-langgraph" -version = "0.1.0" -description = "A sync LangGraph agent with tool calling and streaming" -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 deleted file mode 100644 index 36fcf418f..000000000 --- a/examples/tutorials/00_sync/030_langgraph/tests/test_agent.py +++ /dev/null @@ -1,169 +0,0 @@ -""" -Tests for the sync LangGraph agent. - -This test suite validates: -- Non-streaming message sending with tool-calling LangGraph agent -- Streaming message sending with token-by-token output - -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: 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 - -# 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", "s030-langgraph") - - -@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 LangGraph 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 - - def test_multiturn_conversation(self, client: Agentex, agent_name: str, agent_id: str): - """Test multi-turn conversation with memory via LangGraph checkpointer.""" - task_response = client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) - task = task_response.result - assert task is not None - - # First message - 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 - - # Second message - agent should remember the name - 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: - """Test streaming message sending with LangGraph agent.""" - - 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.""" - 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 deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/00_sync/040_pydantic_ai/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index ba2f17d19..000000000 --- a/examples/tutorials/00_sync/040_pydantic_ai/Dockerfile +++ /dev/null @@ -1,50 +0,0 @@ -# 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 deleted file mode 100644 index 02c3b57c7..000000000 --- a/examples/tutorials/00_sync/040_pydantic_ai/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# Tutorial 040: Sync Pydantic AI Agent - -This tutorial demonstrates how to build a **synchronous** Pydantic AI agent on AgentEx with: -- Tool calling (Pydantic AI handles the tool loop internally) -- Streaming token output (including token-by-token tool-call argument streaming) - -## Key Concepts - -### Sync ACP -The sync ACP model uses HTTP request/response for communication. The `@acp.on_message_send` handler receives a message and yields streaming events back to the client. - -### Pydantic AI Integration -- **Agent**: A single `pydantic_ai.Agent` that owns the model and tools. No graph required โ€” Pydantic AI runs its own tool-call loop until the model is done. -- **`@agent.tool_plain`**: Registers a Python function as a tool. Pydantic AI infers the schema from type hints and docstring. -- **`agent.run_stream_events(...)`**: Yields `AgentStreamEvent`s (PartStartEvent / PartDeltaEvent / PartEndEvent / FunctionToolResultEvent) as the model produces them. - -### Streaming -The agent streams tokens and tool-call arguments as they're generated using `convert_pydantic_ai_to_agentex_events()`, which adapts Pydantic AI's stream into AgentEx `TaskMessageUpdate` events. Notably, **tool-call arguments stream as `ToolRequestDelta` tokens** rather than arriving as a single complete payload โ€” a richer experience than what OpenAI Agents SDK currently exposes. - -## Files - -| File | Description | -|------|-------------| -| `project/acp.py` | ACP server and message handler | -| `project/agent.py` | Pydantic AI agent + tool registration | -| `project/tools.py` | Tool definitions (weather example) | -| `tests/test_agent.py` | Integration tests | -| `manifest.yaml` | Agent configuration | - -## Running Locally - -```bash -# From this directory -agentex agents run -``` - -## Running Tests - -```bash -pytest tests/test_agent.py -v -``` - -## Notes - -- Multi-turn conversation memory is not wired in this tutorial. Pydantic AI does not ship a checkpointer like LangGraph; to add memory, load prior messages via `adk.messages.list(task_id=...)` and pass them to `agent.run_stream_events(..., message_history=...)`. -- Reasoning/thinking tokens are not exercised here because `gpt-4o-mini` does not emit `ThinkingPart`s. Swap to a reasoning-capable model (e.g. `openai:o1-mini` via Pydantic AI's appropriate provider) if you want to test that branch end-to-end. diff --git a/examples/tutorials/00_sync/040_pydantic_ai/manifest.yaml b/examples/tutorials/00_sync/040_pydantic_ai/manifest.yaml deleted file mode 100644 index 68d3b4a00..000000000 --- a/examples/tutorials/00_sync/040_pydantic_ai/manifest.yaml +++ /dev/null @@ -1,58 +0,0 @@ -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 agent with tool calling and streaming - - 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 agent with tool calling and streaming" - 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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/00_sync/040_pydantic_ai/project/acp.py b/examples/tutorials/00_sync/040_pydantic_ai/project/acp.py deleted file mode 100644 index 0c096893f..000000000 --- a/examples/tutorials/00_sync/040_pydantic_ai/project/acp.py +++ /dev/null @@ -1,78 +0,0 @@ -"""ACP (Agent Communication Protocol) handler for Agentex. - -This is the API layer โ€” it owns the agent lifecycle and streams tokens -and tool calls from the Pydantic AI agent to the Agentex frontend. -""" - -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 create_agent -from agentex.lib.adk import ( - create_pydantic_ai_tracing_handler, - convert_pydantic_ai_to_agentex_events, -) -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.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") - -_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 from Agentex, streaming tokens and tool calls.""" - 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: - tracing_handler = create_pydantic_ai_tracing_handler( - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - task_id=task_id, - ) - async with agent.run_stream_events(user_message) as stream: - async for event in convert_pydantic_ai_to_agentex_events(stream, tracing_handler=tracing_handler): - yield event diff --git a/examples/tutorials/00_sync/040_pydantic_ai/project/agent.py b/examples/tutorials/00_sync/040_pydantic_ai/project/agent.py deleted file mode 100644 index 2c0f6f10c..000000000 --- a/examples/tutorials/00_sync/040_pydantic_ai/project/agent.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Pydantic AI agent definition. - -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 - -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 deleted file mode 100644 index bab87942a..000000000 --- a/examples/tutorials/00_sync/040_pydantic_ai/project/tools.py +++ /dev/null @@ -1,20 +0,0 @@ -"""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/examples/tutorials/00_sync/040_pydantic_ai/pyproject.toml b/examples/tutorials/00_sync/040_pydantic_ai/pyproject.toml deleted file mode 100644 index 3e645fa15..000000000 --- a/examples/tutorials/00_sync/040_pydantic_ai/pyproject.toml +++ /dev/null @@ -1,36 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "s040-pydantic-ai" -version = "0.1.0" -description = "A sync Pydantic AI agent with tool calling and streaming" -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 deleted file mode 100644 index d3deed1c7..000000000 --- a/examples/tutorials/00_sync/040_pydantic_ai/tests/test_agent.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Tests for the sync Pydantic AI agent. - -This test suite validates: -- Non-streaming message sending with tool-calling Pydantic AI agent -- Streaming message sending with token-by-token output - -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 Pydantic AI 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 with Pydantic AI agent.""" - - 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. - - This exercises the headline Pydantic AI converter feature: - tool-call argument tokens streaming through as ToolRequestDelta. - """ - 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_local_sandbox/.dockerignore b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/.dockerignore deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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_local_sandbox/Dockerfile b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/Dockerfile deleted file mode 100644 index 8e0ec22df..000000000 --- a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/Dockerfile +++ /dev/null @@ -1,50 +0,0 @@ -# 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_local_sandbox/pyproject.toml /app/050_openai_agents_local_sandbox/pyproject.toml -COPY 00_sync/050_openai_agents_local_sandbox/README.md /app/050_openai_agents_local_sandbox/README.md - -WORKDIR /app/050_openai_agents_local_sandbox - -# Copy the project code -COPY 00_sync/050_openai_agents_local_sandbox/project /app/050_openai_agents_local_sandbox/project - -# Copy the test files -COPY 00_sync/050_openai_agents_local_sandbox/tests /app/050_openai_agents_local_sandbox/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-local-sandbox - -# 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_local_sandbox/README.md b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/README.md deleted file mode 100644 index 9c2c81d7d..000000000 --- a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/README.md +++ /dev/null @@ -1,113 +0,0 @@ -# Tutorial 050: Sync OpenAI Agents SDK with a Local Sandbox - -This tutorial demonstrates how to build a **synchronous** agent on AgentEx using 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. - -## Key Concepts - -### Sync ACP -The sync ACP model uses HTTP request/response for communication. The -`@acp.on_message_send` handler receives a message, runs the agent, and returns the -agent's final answer as a `TextContent`. - -### OpenAI Agents SDK Sandbox -The OpenAI Agents SDK ships `agents.sandbox`, which lets you give an agent -**capabilities** (instead of hand-written tools) that the runtime turns into real -tools backed by a sandbox: - -- **`SandboxAgent`**: an `Agent` that is granted sandbox capabilities. -- **Capabilities** (`from agents.sandbox.capabilities import Shell, Filesystem, Memory`): - each capability expands into a set of real tools. This tutorial uses `Shell`, which - lets the model run real shell commands. -- **`SandboxRunConfig`** + a sandbox **client**: tells the runtime *where* the tools - actually execute. - -### The LOCAL sandbox (`UnixLocalSandboxClient`) -This tutorial uses the local backend -(`from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient, UnixLocalSandboxClientOptions`), -`backend_id="unix_local"`. 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** involved. This makes it the simplest way to give an agent a -real shell. - -The sandbox is wired up through the SDK's `RunConfig`: - -```python -from agents import Runner, set_tracing_disabled -from agents.run_config import RunConfig -from agents.sandbox import SandboxAgent, SandboxRunConfig -from agents.sandbox.capabilities import Shell -from agents.sandbox.sandboxes.unix_local import ( - UnixLocalSandboxClient, - UnixLocalSandboxClientOptions, -) - -set_tracing_disabled(True) # avoid api.openai.com tracing 401 behind a gateway - -agent = SandboxAgent( - name="Local Sandbox Assistant", - instructions="...use the shell tools to actually run commands...", - capabilities=[Shell()], -) -run_config = RunConfig( - sandbox=SandboxRunConfig( - client=UnixLocalSandboxClient(), - options=UnixLocalSandboxClientOptions(), - ) -) -result = await Runner.run(agent, input="what's the python version?", run_config=run_config) -print(result.final_output) -``` - -`Runner.run` drives the full tool-call loop internally: the model issues shell -commands, the local sandbox runs them on the host, the output is fed back, and the -loop continues until the model produces a final answer. - -## Files - -| File | Description | -|------|-------------| -| `project/acp.py` | ACP server and message handler (runs the sandbox agent) | -| `project/agent.py` | `SandboxAgent` + `RunConfig(sandbox=...)` wiring + `run_agent` | -| `project/tools.py` | Sandbox capability factory (`Shell`) | -| `tests/test_agent.py` | Integration tests | -| `manifest.yaml` | Agent configuration | - -## Running Locally - -```bash -# From this directory -agentex agents run -``` - -Set `OPENAI_API_KEY` (or `LITELLM_API_KEY` if you're behind the Scale LiteLLM -gateway) in your environment or in a `.env` file in `project/` so the agent can call -the model. - -## Running Tests - -```bash -pytest tests/test_agent.py -v -``` - -## Notes - -- **No infra required.** Because this uses the `unix_local` backend, the shell tools - run directly in the agent's process โ€” no Docker daemon, no Temporal, no remote - sandbox. Swap the client for a remote/containerized backend to isolate execution. -- **Tracing.** `set_tracing_disabled(True)` turns off the OpenAI Agents SDK's native - tracer (which would otherwise try to ship traces to `api.openai.com`). The manifest - also sets `OPENAI_AGENTS_DISABLE_TRACING=1`. AgentEx/SGP tracing still runs via the - tracing manager configured in `acp.py` when SGP credentials are present. -- **Capabilities are the tools.** To let the agent do more, add capabilities in - `project/tools.py` (e.g. `Filesystem()`, `Memory()`). - -## Further Reading - -- OpenAI Agents SDK guide: https://developers.openai.com/api/docs/guides/agents -- The next evolution of the Agents SDK: https://openai.com/index/the-next-evolution-of-the-agents-sdk/ diff --git a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/manifest.yaml b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/manifest.yaml deleted file mode 100644 index 8ae5b98a1..000000000 --- a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/manifest.yaml +++ /dev/null @@ -1,61 +0,0 @@ -build: - context: - root: ../../ - include_paths: - - 00_sync/050_openai_agents_local_sandbox - - test_utils - dockerfile: 00_sync/050_openai_agents_local_sandbox/Dockerfile - dockerignore: 00_sync/050_openai_agents_local_sandbox/.dockerignore - -local_development: - agent: - port: 8000 - host_address: host.docker.internal - paths: - acp: project/acp.py - -agent: - acp_type: sync - name: s050-openai-agents-local-sandbox - description: A sync OpenAI Agents SDK agent using a local (unix_local) sandbox - - 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 - - env: - OPENAI_AGENTS_DISABLE_TRACING: "1" - -deployment: - image: - repository: "" - tag: "latest" - - global: - agent: - name: "s050-openai-agents-local-sandbox" - description: "A sync OpenAI Agents SDK agent using a local (unix_local) sandbox" - replicaCount: 1 - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" diff --git a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/__init__.py b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/acp.py b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/acp.py deleted file mode 100644 index 005d679bf..000000000 --- a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/acp.py +++ /dev/null @@ -1,77 +0,0 @@ -"""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.lib.types.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 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 - -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, - ) - ) - -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 - 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: - 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/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/agent.py b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/agent.py deleted file mode 100644 index d674d14c9..000000000 --- a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/agent.py +++ /dev/null @@ -1,92 +0,0 @@ -"""OpenAI Agents SDK local-sandbox agent definition. - -This mirrors the Pydantic AI tutorial (040): the agent is the boundary between -this module and the API layer (acp.py). The difference is the runtime โ€” here we -use 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="Local Sandbox Assistant", - 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/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/tools.py b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/tools.py deleted file mode 100644 index 0ad8f25ac..000000000 --- a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/tools.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Sandbox capabilities for the OpenAI Agents SDK local-sandbox agent. - -Unlike the Pydantic AI tutorial (040), 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/examples/tutorials/00_sync/050_openai_agents_local_sandbox/pyproject.toml b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/pyproject.toml deleted file mode 100644 index 472a6bef7..000000000 --- a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/pyproject.toml +++ /dev/null @@ -1,36 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "s050-openai-agents-local-sandbox" -version = "0.1.0" -description = "A sync OpenAI Agents SDK agent using a local (unix_local) sandbox" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "agentex-sdk", - "scale-gp", - "openai-agents>=0.14.3,<0.15", -] - -[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_local_sandbox/tests/test_agent.py b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/tests/test_agent.py deleted file mode 100644 index 52ed1bf2f..000000000 --- a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/tests/test_agent.py +++ /dev/null @@ -1,148 +0,0 @@ -"""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: s050-openai-agents-local-sandbox) -""" - -import os - -import pytest -from test_utils.sync import validate_text_in_string - -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", "s050-openai-agents-local-sandbox") - - -@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. - - Result items may be a bare string, a ``TextContent`` (``.content`` is the - string), or a ``TaskMessage`` wrapping a ``TextContent`` (``.content`` is the - ``TextContent``, whose ``.content`` is the string). Dig through ``.content`` - until we reach a string. - """ - - def _text_of(obj, _depth: int = 0) -> str: - if isinstance(obj, str): - return obj - if _depth > 5: - return "" - inner = getattr(obj, "content", None) - if inner is None: - return "" - return _text_of(inner, _depth + 1) - - parts = [t for t in (_text_of(item) for item in result) if t] - 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". - validate_text_in_string("Python 3", 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." - validate_text_in_string("42", text) - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/00_base/000_hello_acp/.dockerignore b/examples/tutorials/10_async/00_base/000_hello_acp/.dockerignore deleted file mode 100644 index c2d7fca4d..000000000 --- a/examples/tutorials/10_async/00_base/000_hello_acp/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 8b0d20f88..000000000 --- a/examples/tutorials/10_async/00_base/000_hello_acp/Dockerfile +++ /dev/null @@ -1,51 +0,0 @@ -# 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 deleted file mode 100644 index ba8aece1f..000000000 --- a/examples/tutorials/10_async/00_base/000_hello_acp/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# [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 deleted file mode 100644 index 2d5b8800c..000000000 --- a/examples/tutorials/10_async/00_base/000_hello_acp/dev.ipynb +++ /dev/null @@ -1,126 +0,0 @@ -{ - "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 deleted file mode 100644 index ba0c68369..000000000 --- a/examples/tutorials/10_async/00_base/000_hello_acp/manifest.yaml +++ /dev/null @@ -1,122 +0,0 @@ -# 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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 341a22716..000000000 --- a/examples/tutorials/10_async/00_base/000_hello_acp/project/acp.py +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index b65795e84..000000000 --- a/examples/tutorials/10_async/00_base/000_hello_acp/pyproject.toml +++ /dev/null @@ -1,33 +0,0 @@ -[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 deleted file mode 100644 index c57cec448..000000000 --- a/examples/tutorials/10_async/00_base/000_hello_acp/tests/test_agent.py +++ /dev/null @@ -1,191 +0,0 @@ -""" -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 deleted file mode 100644 index c2d7fca4d..000000000 --- a/examples/tutorials/10_async/00_base/010_multiturn/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 48969ad90..000000000 --- a/examples/tutorials/10_async/00_base/010_multiturn/Dockerfile +++ /dev/null @@ -1,51 +0,0 @@ -# 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 deleted file mode 100644 index e16b96c78..000000000 --- a/examples/tutorials/10_async/00_base/010_multiturn/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# [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 deleted file mode 100644 index e174e4705..000000000 --- a/examples/tutorials/10_async/00_base/010_multiturn/dev.ipynb +++ /dev/null @@ -1,126 +0,0 @@ -{ - "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 deleted file mode 100644 index 5d21e78d5..000000000 --- a/examples/tutorials/10_async/00_base/010_multiturn/manifest.yaml +++ /dev/null @@ -1,122 +0,0 @@ -# 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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index a32eed68e..000000000 --- a/examples/tutorials/10_async/00_base/010_multiturn/project/acp.py +++ /dev/null @@ -1,167 +0,0 @@ -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 deleted file mode 100644 index 8b0bb1c19..000000000 --- a/examples/tutorials/10_async/00_base/010_multiturn/pyproject.toml +++ /dev/null @@ -1,33 +0,0 @@ -[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 deleted file mode 100644 index 43d283b8c..000000000 --- a/examples/tutorials/10_async/00_base/010_multiturn/tests/test_agent.py +++ /dev/null @@ -1,221 +0,0 @@ -""" -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 deleted file mode 100644 index c2d7fca4d..000000000 --- a/examples/tutorials/10_async/00_base/020_streaming/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 447ca292d..000000000 --- a/examples/tutorials/10_async/00_base/020_streaming/Dockerfile +++ /dev/null @@ -1,50 +0,0 @@ -# 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 deleted file mode 100644 index 17c19b57d..000000000 --- a/examples/tutorials/10_async/00_base/020_streaming/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# [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 deleted file mode 100644 index f66be24df..000000000 --- a/examples/tutorials/10_async/00_base/020_streaming/dev.ipynb +++ /dev/null @@ -1,126 +0,0 @@ -{ - "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 deleted file mode 100644 index bd5673a6b..000000000 --- a/examples/tutorials/10_async/00_base/020_streaming/manifest.yaml +++ /dev/null @@ -1,119 +0,0 @@ -# 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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 41e44912e..000000000 --- a/examples/tutorials/10_async/00_base/020_streaming/project/acp.py +++ /dev/null @@ -1,144 +0,0 @@ -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 deleted file mode 100644 index 271bcaac9..000000000 --- a/examples/tutorials/10_async/00_base/020_streaming/pyproject.toml +++ /dev/null @@ -1,33 +0,0 @@ -[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 deleted file mode 100644 index c55525191..000000000 --- a/examples/tutorials/10_async/00_base/020_streaming/tests/test_agent.py +++ /dev/null @@ -1,219 +0,0 @@ -""" -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 deleted file mode 100644 index c2d7fca4d..000000000 --- a/examples/tutorials/10_async/00_base/030_tracing/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 2aee7e1dd..000000000 --- a/examples/tutorials/10_async/00_base/030_tracing/Dockerfile +++ /dev/null @@ -1,50 +0,0 @@ -# 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 deleted file mode 100644 index 1d91f565f..000000000 --- a/examples/tutorials/10_async/00_base/030_tracing/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# [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. - -## 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 deleted file mode 100644 index f667737bb..000000000 --- a/examples/tutorials/10_async/00_base/030_tracing/dev.ipynb +++ /dev/null @@ -1,126 +0,0 @@ -{ - "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 deleted file mode 100644 index 3c9b2c147..000000000 --- a/examples/tutorials/10_async/00_base/030_tracing/manifest.yaml +++ /dev/null @@ -1,119 +0,0 @@ -# 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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index a46e77698..000000000 --- a/examples/tutorials/10_async/00_base/030_tracing/project/acp.py +++ /dev/null @@ -1,167 +0,0 @@ -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 deleted file mode 100644 index fe1468a87..000000000 --- a/examples/tutorials/10_async/00_base/030_tracing/pyproject.toml +++ /dev/null @@ -1,33 +0,0 @@ -[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 deleted file mode 100644 index 0cc65c566..000000000 --- a/examples/tutorials/10_async/00_base/030_tracing/tests/test_agent.py +++ /dev/null @@ -1,124 +0,0 @@ -""" -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 deleted file mode 100644 index c2d7fca4d..000000000 --- a/examples/tutorials/10_async/00_base/040_other_sdks/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 2e0ee6ef0..000000000 --- a/examples/tutorials/10_async/00_base/040_other_sdks/Dockerfile +++ /dev/null @@ -1,50 +0,0 @@ -# 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 deleted file mode 100644 index 5c086233b..000000000 --- a/examples/tutorials/10_async/00_base/040_other_sdks/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# [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 deleted file mode 100644 index abb1b9e73..000000000 --- a/examples/tutorials/10_async/00_base/040_other_sdks/dev.ipynb +++ /dev/null @@ -1,126 +0,0 @@ -{ - "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 deleted file mode 100644 index 8fd324c13..000000000 --- a/examples/tutorials/10_async/00_base/040_other_sdks/manifest.yaml +++ /dev/null @@ -1,119 +0,0 @@ -# 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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index d2ec84fcd..000000000 --- a/examples/tutorials/10_async/00_base/040_other_sdks/project/acp.py +++ /dev/null @@ -1,375 +0,0 @@ -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 deleted file mode 100644 index 2d6695120..000000000 --- a/examples/tutorials/10_async/00_base/040_other_sdks/pyproject.toml +++ /dev/null @@ -1,33 +0,0 @@ -[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 deleted file mode 100644 index 1704a2271..000000000 --- a/examples/tutorials/10_async/00_base/040_other_sdks/tests/test_agent.py +++ /dev/null @@ -1,426 +0,0 @@ -""" -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 deleted file mode 100644 index c4f7a8b4b..000000000 --- a/examples/tutorials/10_async/00_base/080_batch_events/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index dbeccdfb9..000000000 --- a/examples/tutorials/10_async/00_base/080_batch_events/Dockerfile +++ /dev/null @@ -1,51 +0,0 @@ -# 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 deleted file mode 100644 index b49e01873..000000000 --- a/examples/tutorials/10_async/00_base/080_batch_events/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# [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 deleted file mode 100644 index 5bb98625c..000000000 --- a/examples/tutorials/10_async/00_base/080_batch_events/dev.ipynb +++ /dev/null @@ -1,155 +0,0 @@ -{ - "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 deleted file mode 100644 index dd5f8cbdc..000000000 --- a/examples/tutorials/10_async/00_base/080_batch_events/manifest.yaml +++ /dev/null @@ -1,117 +0,0 @@ -# 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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 94e79068b..000000000 --- a/examples/tutorials/10_async/00_base/080_batch_events/project/acp.py +++ /dev/null @@ -1,235 +0,0 @@ -""" -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 deleted file mode 100644 index a38bfbb6c..000000000 --- a/examples/tutorials/10_async/00_base/080_batch_events/pyproject.toml +++ /dev/null @@ -1,33 +0,0 @@ -[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 deleted file mode 100644 index b7a5397d0..000000000 --- a/examples/tutorials/10_async/00_base/080_batch_events/test_batch_events.py +++ /dev/null @@ -1,112 +0,0 @@ -#!/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 deleted file mode 100644 index 1dea1300b..000000000 --- a/examples/tutorials/10_async/00_base/080_batch_events/tests/test_agent.py +++ /dev/null @@ -1,233 +0,0 @@ -""" -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 deleted file mode 100644 index 24ecf4484..000000000 --- a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/Dockerfile +++ /dev/null @@ -1,57 +0,0 @@ -# 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 deleted file mode 100644 index d9f860e30..000000000 --- a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/README.md +++ /dev/null @@ -1,210 +0,0 @@ -# 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 deleted file mode 100644 index 9d531bbf4..000000000 --- a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/creator.yaml +++ /dev/null @@ -1,42 +0,0 @@ -# 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 deleted file mode 100644 index 0a18fc127..000000000 --- a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/critic.yaml +++ /dev/null @@ -1,42 +0,0 @@ -# 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 deleted file mode 100644 index 9c69b74c6..000000000 --- a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/formatter.yaml +++ /dev/null @@ -1,42 +0,0 @@ -# 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 deleted file mode 100644 index 079329fd0..000000000 --- a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/orchestrator.yaml +++ /dev/null @@ -1,42 +0,0 @@ -# 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 deleted file mode 100644 index 4d299677c..000000000 --- a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# 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 deleted file mode 100644 index 316975486..000000000 --- a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/creator.py +++ /dev/null @@ -1,294 +0,0 @@ -# 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 deleted file mode 100644 index e58ea44ae..000000000 --- a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/critic.py +++ /dev/null @@ -1,312 +0,0 @@ -# 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 deleted file mode 100644 index 3301d066b..000000000 --- a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/formatter.py +++ /dev/null @@ -1,327 +0,0 @@ -# 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 deleted file mode 100644 index e9aef6d75..000000000 --- a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/models.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -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 deleted file mode 100644 index f9aea8be4..000000000 --- a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/orchestrator.py +++ /dev/null @@ -1,419 +0,0 @@ -# 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 deleted file mode 100644 index 1b5b70b5c..000000000 --- a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/state_machines/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# 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 deleted file mode 100644 index 389b05751..000000000 --- a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/project/state_machines/content_workflow.py +++ /dev/null @@ -1,307 +0,0 @@ -# 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 deleted file mode 100644 index a2234c45e..000000000 --- a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/pyproject.toml +++ /dev/null @@ -1,33 +0,0 @@ -[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", - "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 deleted file mode 100755 index 783463635..000000000 --- a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/start-agents.sh +++ /dev/null @@ -1,327 +0,0 @@ -#!/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 deleted file mode 100644 index 8af941a81..000000000 --- a/examples/tutorials/10_async/00_base/090_multi_agent_non_temporal/tests/test_agent.py +++ /dev/null @@ -1,250 +0,0 @@ -""" -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 deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/10_async/00_base/100_langgraph/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index c2e4b464c..000000000 --- a/examples/tutorials/10_async/00_base/100_langgraph/Dockerfile +++ /dev/null @@ -1,50 +0,0 @@ -# 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 deleted file mode 100644 index 6f6c6a36b..000000000 --- a/examples/tutorials/10_async/00_base/100_langgraph/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# Tutorial 100: Async LangGraph Agent - -This tutorial demonstrates how to build 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 - -![Graph](graph.png) - -## Sync vs Async: Key Differences - -| Aspect | Sync (Tutorial 030) | Async (This Tutorial) | -|--------|--------------------|-----------------------| -| **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()` | -| **Extra Handlers** | None | `on_task_create`, `on_task_cancel` | - -### 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 - -## Files - -| File | Description | -|------|-------------| -| `project/acp.py` | ACP server with async event handlers | -| `project/graph.py` | LangGraph state graph definition | -| `project/tools.py` | Tool definitions (weather example) | -| `tests/test_agent.py` | Integration tests | -| `manifest.yaml` | Agent configuration | - -## Running Locally - -```bash -# From this directory -agentex agents run -``` - -## Running Tests - -```bash -pytest tests/test_agent.py -v -``` diff --git a/examples/tutorials/10_async/00_base/100_langgraph/graph.png b/examples/tutorials/10_async/00_base/100_langgraph/graph.png deleted file mode 100644 index 16d22a1e7..000000000 Binary files a/examples/tutorials/10_async/00_base/100_langgraph/graph.png and /dev/null differ diff --git a/examples/tutorials/10_async/00_base/100_langgraph/manifest.yaml b/examples/tutorials/10_async/00_base/100_langgraph/manifest.yaml deleted file mode 100644 index 1b0b5d490..000000000 --- a/examples/tutorials/10_async/00_base/100_langgraph/manifest.yaml +++ /dev/null @@ -1,58 +0,0 @@ -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 with tool calling and Redis streaming - - 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 with tool calling and Redis streaming" - 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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 2585fefd6..000000000 --- a/examples/tutorials/10_async/00_base/100_langgraph/project/acp.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -ACP handler for async LangGraph agent. - -Uses the async ACP model with Redis streaming instead of HTTP yields. -""" - -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.adk import stream_langgraph_events, create_langgraph_tracing_handler -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.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 Redis.""" - graph = await get_graph() - task_id = params.task.id - user_message = params.event.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: - callback = create_langgraph_tracing_handler( - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - ) - - stream = graph.astream( - {"messages": [{"role": "user", "content": user_message}]}, - config={ - "configurable": {"thread_id": task_id}, - "callbacks": [callback], - }, - stream_mode=["messages", "updates"], - ) - - final_output = await stream_langgraph_events(stream, task_id) - - if turn_span: - turn_span.output = {"final_output": final_output} - - -@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 deleted file mode 100644 index af6e31313..000000000 --- a/examples/tutorials/10_async/00_base/100_langgraph/project/graph.py +++ /dev/null @@ -1,68 +0,0 @@ -""" -LangGraph graph definition. - -Defines the state, nodes, edges, and compiles the graph. -""" - -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 deleted file mode 100644 index 1b402a906..000000000 --- a/examples/tutorials/10_async/00_base/100_langgraph/project/tools.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -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/examples/tutorials/10_async/00_base/100_langgraph/pyproject.toml b/examples/tutorials/10_async/00_base/100_langgraph/pyproject.toml deleted file mode 100644 index fecbc6149..000000000 --- a/examples/tutorials/10_async/00_base/100_langgraph/pyproject.toml +++ /dev/null @@ -1,37 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "ab100-langgraph" -version = "0.1.0" -description = "An async LangGraph agent with tool calling and Redis streaming" -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 deleted file mode 100644 index 948db1558..000000000 --- a/examples/tutorials/10_async/00_base/100_langgraph/tests/test_agent.py +++ /dev/null @@ -1,123 +0,0 @@ -""" -Tests for the async LangGraph agent. - -This test suite validates: -- 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: 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 - -# 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", "ab100-langgraph") - - -@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(self, client: AsyncAgentex, agent_id: str): - """Test sending an event to the async LangGraph 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/110_pydantic_ai/.dockerignore b/examples/tutorials/10_async/00_base/110_pydantic_ai/.dockerignore deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/10_async/00_base/110_pydantic_ai/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 906d62068..000000000 --- a/examples/tutorials/10_async/00_base/110_pydantic_ai/Dockerfile +++ /dev/null @@ -1,50 +0,0 @@ -# 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 deleted file mode 100644 index 6046b579a..000000000 --- a/examples/tutorials/10_async/00_base/110_pydantic_ai/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# Tutorial 110 (async/base): Pydantic AI Agent - -This tutorial demonstrates how to build an **async** Pydantic AI agent on AgentEx with: -- Tool calling (Pydantic AI handles the tool loop internally) -- Streaming token output via Redis (text + reasoning tokens stream as deltas) -- Task lifecycle hooks (create / event-send / cancel) - -This is the async counterpart to the sync tutorial at [`00_sync/040_pydantic_ai`](../../../00_sync/040_pydantic_ai/). - -## Key Concepts - -### Async ACP -Unlike sync ACP (HTTP request/response with chunked streaming back), async ACP uses **Redis** for streaming. The HTTP call returns immediately when an event is acknowledged; the agent then pushes updates to Redis on its own schedule. The UI subscribes to Redis to receive deltas. - -### Pydantic AI Integration -- **Agent**: A single `pydantic_ai.Agent` that owns the model and tools. No graph required. -- **`@agent.tool_plain`**: Registers a Python function as a tool. Pydantic AI infers the schema from type hints and docstring. -- **`agent.run_stream_events(...)`**: Yields `AgentStreamEvent`s (`PartStartEvent` / `PartDeltaEvent` / `PartEndEvent` / `FunctionToolResultEvent`) as the model produces them. - -### Streaming -The helper `stream_pydantic_ai_events(stream, task_id)` consumes the Pydantic AI event stream and writes Agentex updates to Redis via `adk.streaming.streaming_task_message_context(...)`: -- **Text and thinking tokens** stream as Redis deltas inside coalesced contexts. -- **Tool requests and tool responses** are emitted as **discrete full messages** (no token-level arg streaming). To stream tool-call argument tokens, use the sync converter โ€” see [`00_sync/040_pydantic_ai`](../../../00_sync/040_pydantic_ai/). - -## Files - -| File | Description | -|------|-------------| -| `project/acp.py` | Async ACP server with task lifecycle handlers | -| `project/agent.py` | Pydantic AI agent + tool registration | -| `project/tools.py` | Tool definitions (weather example) | -| `tests/test_agent.py` | Integration tests | -| `manifest.yaml` | Agent configuration | - -## Running Locally - -```bash -# From this directory -agentex agents run -``` - -## Running Tests - -```bash -pytest tests/test_agent.py -v -``` - -## Sync vs Async โ€” How the Code Differs - -This tutorial uses the same `project/agent.py` and `project/tools.py` as the sync version. The only meaningful differences live in `project/acp.py`: - -| Concern | Sync (`s040-pydantic-ai`) | Async (`ab110-pydantic-ai`) | -|---|---|---| -| ACP type | `FastACP.create(acp_type="sync")` | `FastACP.create(acp_type="async", config=AsyncACPConfig(type="base"))` | -| Handler hook | `@acp.on_message_send` returns/yields events | `@acp.on_task_event_send` returns nothing | -| Stream output | `yield event` (chunked HTTP) | `await context.stream_update(...)` (Redis) | -| Tool calls | Args stream as `ToolRequestDelta` tokens | Args arrive in one full message | -| Lifecycle | Ephemeral (no task hooks) | `on_task_create` + `on_task_cancel` form a durable task contract | - -## Notes - -- Multi-turn conversation memory is not wired here. Pydantic AI does not ship a checkpointer; to add memory, load prior messages via `adk.messages.list(task_id=...)` and pass them to `agent.run_stream_events(..., message_history=...)`. -- Reasoning/thinking tokens are not exercised by `gpt-4o-mini`. Swap to a reasoning-capable model if you want to test that branch end-to-end. 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 deleted file mode 100644 index 583b07251..000000000 --- a/examples/tutorials/10_async/00_base/110_pydantic_ai/manifest.yaml +++ /dev/null @@ -1,58 +0,0 @@ -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 agent with tool calling and Redis streaming - - 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 agent with tool calling and Redis streaming" - 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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index dc8a2de21..000000000 --- a/examples/tutorials/10_async/00_base/110_pydantic_ai/project/acp.py +++ /dev/null @@ -1,164 +0,0 @@ -"""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 pydantic_ai.run import AgentRunResultEvent -from pydantic_ai.messages import ModelMessagesTypeAdapter - -import agentex.lib.adk as adk -from project.agent import create_agent -from agentex.lib.adk import ( - stream_pydantic_ai_events, - create_pydantic_ai_tracing_handler, -) -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.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"), -) - -_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. 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. - - A fresh task starts with no message history; the conversation is built - up by ``handle_task_event_send`` on each subsequent user message. - """ - 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 - 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. If state is missing - # (e.g. task wasn't initialised via on_task_create), fall back to a fresh - # one so the agent still responds โ€” just without memory of prior turns. - 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: - tracing_handler = create_pydantic_ai_tracing_handler( - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - task_id=task_id, - ) - - # Wrap the pydantic-ai event stream so we can capture the final - # AgentRunResultEvent (which carries the full message list for the - # next turn) without changing the streaming-helper's signature. - 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: - final_output = await stream_pydantic_ai_events( - tee_messages(stream), task_id, tracing_handler=tracing_handler - ) - - # 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": final_output} - - -@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 deleted file mode 100644 index 2c0f6f10c..000000000 --- a/examples/tutorials/10_async/00_base/110_pydantic_ai/project/agent.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Pydantic AI agent definition. - -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 - -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 deleted file mode 100644 index 98f65d509..000000000 --- a/examples/tutorials/10_async/00_base/110_pydantic_ai/project/tools.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Tool definitions for the async 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/examples/tutorials/10_async/00_base/110_pydantic_ai/pyproject.toml b/examples/tutorials/10_async/00_base/110_pydantic_ai/pyproject.toml deleted file mode 100644 index f5cd32e0a..000000000 --- a/examples/tutorials/10_async/00_base/110_pydantic_ai/pyproject.toml +++ /dev/null @@ -1,36 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "ab110-pydantic-ai" -version = "0.1.0" -description = "An async Pydantic AI agent with tool calling and Redis streaming" -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 deleted file mode 100644 index a31322d30..000000000 --- a/examples/tutorials/10_async/00_base/110_pydantic_ai/tests/test_agent.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Tests for the async Pydantic AI agent. - -This test suite validates: -- 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: 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 and polling.""" - - @pytest.mark.asyncio - async def test_send_event(self, client: AsyncAgentex, agent_id: str): - """Test sending an event to the async 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_local_sandbox/.dockerignore b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/.dockerignore deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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_local_sandbox/Dockerfile b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/Dockerfile deleted file mode 100644 index 1272027cf..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/Dockerfile +++ /dev/null @@ -1,50 +0,0 @@ -# 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_local_sandbox/pyproject.toml /app/120_openai_agents_local_sandbox/pyproject.toml -COPY 10_async/00_base/120_openai_agents_local_sandbox/README.md /app/120_openai_agents_local_sandbox/README.md - -WORKDIR /app/120_openai_agents_local_sandbox - -# Copy the project code -COPY 10_async/00_base/120_openai_agents_local_sandbox/project /app/120_openai_agents_local_sandbox/project - -# Copy the test files -COPY 10_async/00_base/120_openai_agents_local_sandbox/tests /app/120_openai_agents_local_sandbox/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-local-sandbox - -# 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_local_sandbox/README.md b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/README.md deleted file mode 100644 index 58d422b39..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/README.md +++ /dev/null @@ -1,119 +0,0 @@ -# Tutorial 120: Async OpenAI Agents SDK with a Local Sandbox - -This tutorial demonstrates how to build an **async (non-Temporal)** agent on AgentEx -using 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. - -This mirrors the Pydantic AI async tutorial (`110_pydantic_ai`): same async ACP -model (`acp_type: async`, `temporal.enabled: false`), same per-task `adk.state` -multi-turn memory pattern. The difference is the runtime โ€” here we use the OpenAI -Agents SDK `SandboxAgent` with the local sandbox backend. - -## Key Concepts - -### Async ACP (base) -The async ACP model is event-driven: `on_task_create` initializes per-task state, -and `on_task_event_send` handles each user message. Conversation history is -persisted across turns via `adk.state`. - -### OpenAI Agents SDK Sandbox -The OpenAI Agents SDK ships `agents.sandbox`, which lets you give an agent -**capabilities** (instead of hand-written tools) that the runtime turns into real -tools backed by a sandbox: - -- **`SandboxAgent`**: an `Agent` that is granted sandbox capabilities. -- **Capabilities** (`from agents.sandbox.capabilities import Shell, Filesystem, Memory`): - each capability expands into a set of real tools. This tutorial uses `Shell`, which - lets the model run real shell commands. -- **`SandboxRunConfig`** + a sandbox **client**: tells the runtime *where* the tools - actually execute. - -### The LOCAL sandbox (`UnixLocalSandboxClient`) -This tutorial uses the local backend -(`from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient, UnixLocalSandboxClientOptions`), -`backend_id="unix_local"`. 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** involved. - -The sandbox is wired up through the SDK's `RunConfig`: - -```python -from agents import Runner, set_tracing_disabled -from agents.run_config import RunConfig -from agents.sandbox import SandboxAgent, SandboxRunConfig -from agents.sandbox.capabilities import Shell -from agents.sandbox.sandboxes.unix_local import ( - UnixLocalSandboxClient, - UnixLocalSandboxClientOptions, -) - -set_tracing_disabled(True) # avoid api.openai.com tracing 401 behind a gateway - -agent = SandboxAgent( - name="Local Sandbox Assistant", - instructions="...use the shell tools to actually run commands...", - capabilities=[Shell()], -) -run_config = RunConfig( - sandbox=SandboxRunConfig( - client=UnixLocalSandboxClient(), - options=UnixLocalSandboxClientOptions(), - ) -) -result = await Runner.run(agent, input=input_list, run_config=run_config) -print(result.final_output) -``` - -`Runner.run` drives the full tool-call loop internally: the model issues shell -commands, the local sandbox runs them on the host, the output is fed back, and the -loop continues until the model produces a final answer. Because the loop is -self-contained, the async handler runs the agent and persists a single final -`TextContent` rather than streaming tokens. - -## Files - -| File | Description | -|------|-------------| -| `project/acp.py` | Async ACP server + handlers (`adk.state` multi-turn, runs the sandbox agent) | -| `project/agent.py` | `SandboxAgent` + `RunConfig(sandbox=...)` wiring + `run_agent` | -| `project/tools.py` | Sandbox capability factory (`Shell`) | -| `tests/test_agent.py` | Integration tests (polling pattern) | -| `manifest.yaml` | Agent configuration | - -## Running Locally - -```bash -# From this directory -agentex agents run -``` - -Set `OPENAI_API_KEY` (or `LITELLM_API_KEY` if you're behind the Scale LiteLLM -gateway) in your environment or in a `.env` file in `project/` so the agent can call -the model. - -## Running Tests - -```bash -pytest tests/test_agent.py -v -``` - -## Notes - -- **No infra required.** Because this uses the `unix_local` backend, the shell tools - run directly in the agent's process โ€” no Docker daemon, no Temporal, no remote - sandbox. Swap the client for a remote/containerized backend to isolate execution. -- **Tracing.** `set_tracing_disabled(True)` turns off the OpenAI Agents SDK's native - tracer (which would otherwise try to ship traces to `api.openai.com`). The manifest - also sets `OPENAI_AGENTS_DISABLE_TRACING=1`. AgentEx/SGP tracing still runs via the - tracing manager configured in `acp.py` when SGP credentials are present. -- **Capabilities are the tools.** To let the agent do more, add capabilities in - `project/tools.py` (e.g. `Filesystem()`, `Memory()`). - -## Further Reading - -- OpenAI Agents SDK guide: https://developers.openai.com/api/docs/guides/agents -- The Temporal variant of this tutorial: `10_async/10_temporal/120_openai_agents_local_sandbox` diff --git a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/manifest.yaml b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/manifest.yaml deleted file mode 100644 index e0c3c0596..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/manifest.yaml +++ /dev/null @@ -1,61 +0,0 @@ -build: - context: - root: ../../../ - include_paths: - - 10_async/00_base/120_openai_agents_local_sandbox - - test_utils - dockerfile: 10_async/00_base/120_openai_agents_local_sandbox/Dockerfile - dockerignore: 10_async/00_base/120_openai_agents_local_sandbox/.dockerignore - -local_development: - agent: - port: 8000 - host_address: host.docker.internal - paths: - acp: project/acp.py - -agent: - acp_type: async - name: ab120-openai-agents-local-sandbox - description: An async OpenAI Agents SDK agent using a local (unix_local) sandbox - - 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 - - env: - OPENAI_AGENTS_DISABLE_TRACING: "1" - -deployment: - image: - repository: "" - tag: "latest" - - global: - agent: - name: "ab120-openai-agents-local-sandbox" - description: "An async OpenAI Agents SDK agent using a local (unix_local) sandbox" - 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_local_sandbox/project/__init__.py b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/acp.py b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/acp.py deleted file mode 100644 index 6ff475873..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/acp.py +++ /dev/null @@ -1,149 +0,0 @@ -"""ACP handler for the async OpenAI Agents SDK local-sandbox agent. - -Uses the async ACP model (``acp_type: async``, ``temporal.enabled: false``), -mirroring the Pydantic AI tutorial (110). The difference is the runtime: here we -run an OpenAI Agents SDK ``SandboxAgent`` against the **local** sandbox backend -(``UnixLocalSandboxClient``), which executes real shell commands on the host. - -The OpenAI Agents SDK sandbox runtime drives the full tool-call loop internally -inside ``Runner.run`` (model -> shell command -> output -> model -> ... -> final -answer), so this handler runs the agent and persists a single final -``TextContent`` rather than streaming tokens itself. - -Multi-turn memory is persisted via ``adk.state``: on each turn we load the prior -OpenAI Agents SDK input list from state, run the agent with it, then save the -updated list (``result.to_input_list()``) 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 - -from dotenv import load_dotenv - -load_dotenv() - -import agentex.lib.adk as adk -from project.agent import run_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.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.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="async", - config=AsyncACPConfig(type="base"), -) - - -class ConversationState(BaseModel): - """Per-task conversation state persisted via ``adk.state``. - - ``input_list`` holds the OpenAI Agents SDK conversation history โ€” the same - structure ``Runner.run`` accepts as input and ``result.to_input_list()`` - returns. Persisting it between turns gives the agent multi-turn memory. - """ - - input_list: list[dict[str, Any]] = [] - turn_number: int = 0 - - -@acp.on_task_create -async def handle_task_create(params: CreateTaskParams): - """Initialize per-task state on task creation. - - A fresh task starts with no message history; the conversation is built up by - ``handle_task_event_send`` on each subsequent user message. - """ - 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.""" - 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 so it shows up in the UI. - await adk.messages.create(task_id=task_id, content=params.event.content) - - # Load the previous conversation history from state. If state is missing - # (e.g. task wasn't initialised via on_task_create), fall back to a fresh - # one so the agent still responds โ€” just without memory of prior turns. - 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 - state.input_list.append({"role": "user", "content": user_message}) - - 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: - # The OpenAI Agents SDK sandbox runtime runs the full tool-call loop - # internally (model -> shell command on the local host -> output -> - # model -> ... -> final answer), so we get a single final result. - result = await run_agent(state.input_list) - final_output = result.final_output - - # Persist the assistant's final answer as a TaskMessage so it shows up - # in the UI. (Unlike the streaming Pydantic AI tutorial, the sandbox run - # is non-streaming, so we post the final text ourselves.) - await adk.messages.create( - task_id=task_id, - content=TextContent(author="agent", content=final_output), - ) - - # Save the updated message history so the next turn picks up here. - 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": final_output} - - -@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_local_sandbox/project/agent.py b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/agent.py deleted file mode 100644 index 177bb287d..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/agent.py +++ /dev/null @@ -1,95 +0,0 @@ -"""OpenAI Agents SDK local-sandbox agent definition (async, non-Temporal). - -This mirrors the Pydantic AI tutorial (110): the agent is the boundary between -this module and the API layer (acp.py). The difference is the runtime โ€” here we -use 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="Local Sandbox Assistant", - 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(input_list: list) -> "Runner": - """Run the sandbox agent over the conversation so far and return the result. - - 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. - - We pass the full ``input_list`` (prior turns + the new user message) so the - agent has conversation memory across turns; the caller persists - ``result.to_input_list()`` back into ``adk.state`` for the next turn. - """ - agent = create_agent() - run_config = create_run_config() - return await Runner.run(agent, input=input_list, run_config=run_config, max_turns=10) diff --git a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/tools.py b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/tools.py deleted file mode 100644 index a931fa273..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/tools.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Sandbox capabilities for the async OpenAI Agents SDK local-sandbox agent. - -Unlike the Pydantic AI tutorial (110), 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/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/pyproject.toml b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/pyproject.toml deleted file mode 100644 index 75c6254f3..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/pyproject.toml +++ /dev/null @@ -1,36 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "ab120-openai-agents-local-sandbox" -version = "0.1.0" -description = "An async OpenAI Agents SDK agent using a local (unix_local) sandbox" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "agentex-sdk", - "scale-gp", - "openai-agents>=0.14.3,<0.15", -] - -[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_local_sandbox/tests/test_agent.py b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/tests/test_agent.py deleted file mode 100644 index 0c7904eac..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/tests/test_agent.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Tests for the async OpenAI Agents SDK local-sandbox agent. - -This test suite validates that the agent actually runs shell commands in the -LOCAL sandbox (unix_local backend) by polling for the agent's response: -- Ask for the Python version -> response contains "Python 3" -- Ask it to compute 21 * 2 with python3 -> response contains "42" - -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: ab120-openai-agents-local-sandbox) -""" - -import os -import uuid - -import pytest -import pytest_asyncio -from test_utils.async_utils import send_event_and_poll_yielding - -from agentex import AsyncAgentex -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", "ab120-openai-agents-local-sandbox") - - -@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.") - - -async def _send_and_collect_agent_text( - client: AsyncAgentex, agent_id: str, task_id: str, user_message: str -) -> str: - """Send a user message and accumulate all agent text responses into a string.""" - parts: list[str] = [] - 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, - yield_updates=True, - ): - content = message.content - if content and content.type == "text" and content.author == "agent": - if content.content and content.content not in parts: - parts.append(content.content) - return "\n".join(parts) - - -class TestLocalSandboxEvents: - """Test the async local-sandbox OpenAI Agents SDK agent.""" - - @pytest.mark.asyncio - async def test_shell_python_version(self, client: AsyncAgentex, agent_id: str): - """The agent should run `python3 --version` in the local sandbox. - - The sandbox runs on Python 3.12, so the real output contains "Python 3". - """ - task_response = await client.agents.create_task( - agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex) - ) - task = task_response.result - assert task is not None - - text = await _send_and_collect_agent_text( - client, - agent_id, - task.id, - "Use your shell to print the Python version on this machine, then " - "tell me what it is.", - ) - assert text, "Expected a non-empty response from the sandbox agent." - assert "Python 3" in text - - @pytest.mark.asyncio - async def test_shell_compute(self, client: AsyncAgentex, agent_id: str): - """The agent should use python3 in the sandbox to compute 21 * 2 == 42.""" - task_response = await client.agents.create_task( - agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex) - ) - task = task_response.result - assert task is not None - - text = await _send_and_collect_agent_text( - client, - agent_id, - task.id, - "Use python3 in your shell to compute 21 * 2 and tell me the 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/examples/tutorials/10_async/10_temporal/000_hello_acp/.dockerignore b/examples/tutorials/10_async/10_temporal/000_hello_acp/.dockerignore deleted file mode 100644 index c2d7fca4d..000000000 --- a/examples/tutorials/10_async/10_temporal/000_hello_acp/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index e739eb4a8..000000000 --- a/examples/tutorials/10_async/10_temporal/000_hello_acp/Dockerfile +++ /dev/null @@ -1,59 +0,0 @@ -# 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 deleted file mode 100644 index 95d8f8527..000000000 --- a/examples/tutorials/10_async/10_temporal/000_hello_acp/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# [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 deleted file mode 100644 index f8a66a0ff..000000000 --- a/examples/tutorials/10_async/10_temporal/000_hello_acp/dev.ipynb +++ /dev/null @@ -1,126 +0,0 @@ -{ - "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 deleted file mode 100644 index e93fe8eca..000000000 --- a/examples/tutorials/10_async/10_temporal/000_hello_acp/manifest.yaml +++ /dev/null @@ -1,139 +0,0 @@ -# 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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 744068d77..000000000 --- a/examples/tutorials/10_async/10_temporal/000_hello_acp/project/acp.py +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 7db2fcdc8..000000000 --- a/examples/tutorials/10_async/10_temporal/000_hello_acp/project/run_worker.py +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 2ca0858ba..000000000 --- a/examples/tutorials/10_async/10_temporal/000_hello_acp/project/workflow.py +++ /dev/null @@ -1,71 +0,0 @@ -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. - 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. 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/000_hello_acp/pyproject.toml b/examples/tutorials/10_async/10_temporal/000_hello_acp/pyproject.toml deleted file mode 100644 index ace358668..000000000 --- a/examples/tutorials/10_async/10_temporal/000_hello_acp/pyproject.toml +++ /dev/null @@ -1,34 +0,0 @@ -[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 deleted file mode 100644 index 65204ac36..000000000 --- a/examples/tutorials/10_async/10_temporal/000_hello_acp/tests/test_agent.py +++ /dev/null @@ -1,189 +0,0 @@ -""" -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 deleted file mode 100644 index c2d7fca4d..000000000 --- a/examples/tutorials/10_async/10_temporal/010_agent_chat/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 5ecf911b0..000000000 --- a/examples/tutorials/10_async/10_temporal/010_agent_chat/Dockerfile +++ /dev/null @@ -1,59 +0,0 @@ -# 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 deleted file mode 100644 index 37c31f135..000000000 --- a/examples/tutorials/10_async/10_temporal/010_agent_chat/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# [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 deleted file mode 100644 index 3cb9b822e..000000000 --- a/examples/tutorials/10_async/10_temporal/010_agent_chat/dev.ipynb +++ /dev/null @@ -1,1562 +0,0 @@ -{ - "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 deleted file mode 100644 index 1d53a7c2b..000000000 --- a/examples/tutorials/10_async/10_temporal/010_agent_chat/manifest.yaml +++ /dev/null @@ -1,139 +0,0 @@ -# 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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 744068d77..000000000 --- a/examples/tutorials/10_async/10_temporal/010_agent_chat/project/acp.py +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 31a3c98c2..000000000 --- a/examples/tutorials/10_async/10_temporal/010_agent_chat/project/run_worker.py +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 3e3ac5b27..000000000 --- a/examples/tutorials/10_async/10_temporal/010_agent_chat/project/workflow.py +++ /dev/null @@ -1,276 +0,0 @@ -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 deleted file mode 100644 index 799fa5fe1..000000000 --- a/examples/tutorials/10_async/10_temporal/010_agent_chat/pyproject.toml +++ /dev/null @@ -1,35 +0,0 @@ -[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 deleted file mode 100644 index acbb88754..000000000 --- a/examples/tutorials/10_async/10_temporal/010_agent_chat/tests/test_agent.py +++ /dev/null @@ -1,291 +0,0 @@ -""" -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 - ): - msg_type = event.get("type") - if msg_type == "full": - task_message_update = StreamTaskMessageFull.model_validate(event) - if task_message_update.parent_task_message and task_message_update.parent_task_message.id: - finished_message = await client.messages.retrieve(task_message_update.parent_task_message.id) - if ( - finished_message.content - and finished_message.content.type == "text" - and finished_message.content.author == "user" - ): - user_message_found = True - elif ( - finished_message.content - and finished_message.content.type == "text" - and finished_message.content.author == "agent" - ): - agent_response_found = True - elif finished_message.content and finished_message.content.type == "reasoning": - reasoning_found = True - - # Exit early if we have what we need - if user_message_found and agent_response_found: - break - - 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) - if finished_message.content and finished_message.content.type == "reasoning": - reasoning_found = True - elif ( - finished_message.content - and finished_message.content.type == "text" - and finished_message.content.author == "agent" - ): - agent_response_found = True - 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 deleted file mode 100644 index c2d7fca4d..000000000 --- a/examples/tutorials/10_async/10_temporal/020_state_machine/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 59051b4b8..000000000 --- a/examples/tutorials/10_async/10_temporal/020_state_machine/Dockerfile +++ /dev/null @@ -1,60 +0,0 @@ -# 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 deleted file mode 100644 index 498140006..000000000 --- a/examples/tutorials/10_async/10_temporal/020_state_machine/README.md +++ /dev/null @@ -1,70 +0,0 @@ -# [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 deleted file mode 100644 index 8f9f4dff1..000000000 --- a/examples/tutorials/10_async/10_temporal/020_state_machine/dev.ipynb +++ /dev/null @@ -1,167 +0,0 @@ -{ - "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 deleted file mode 100644 index 8b2bca147..000000000 --- a/examples/tutorials/10_async/10_temporal/020_state_machine/manifest.yaml +++ /dev/null @@ -1,138 +0,0 @@ -# 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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 744068d77..000000000 --- a/examples/tutorials/10_async/10_temporal/020_state_machine/project/acp.py +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 2f0059d51..000000000 --- a/examples/tutorials/10_async/10_temporal/020_state_machine/project/run_worker.py +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index d1c4df00a..000000000 --- a/examples/tutorials/10_async/10_temporal/020_state_machine/project/state_machines/deep_research.py +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index aa88de687..000000000 --- a/examples/tutorials/10_async/10_temporal/020_state_machine/project/workflow.py +++ /dev/null @@ -1,154 +0,0 @@ -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 deleted file mode 100644 index c8e756b20..000000000 --- a/examples/tutorials/10_async/10_temporal/020_state_machine/project/workflows/deep_research/clarify_user_query.py +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index 954a7566b..000000000 --- a/examples/tutorials/10_async/10_temporal/020_state_machine/project/workflows/deep_research/performing_deep_research.py +++ /dev/null @@ -1,155 +0,0 @@ -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__) - -MCP_SERVERS = [ - StdioServerParameters( - command="uvx", - args=["mcp-server-time", "--local-timezone", "America/Los_Angeles"], - ), - StdioServerParameters( - command="uvx", - args=["openai-websearch-mcp"], - env={ - "OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY", "") - } - ), - StdioServerParameters( - command="uvx", - args=["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 deleted file mode 100644 index 842c5c423..000000000 --- a/examples/tutorials/10_async/10_temporal/020_state_machine/project/workflows/deep_research/waiting_for_user_input.py +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index e018b3229..000000000 --- a/examples/tutorials/10_async/10_temporal/020_state_machine/pyproject.toml +++ /dev/null @@ -1,34 +0,0 @@ -[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 deleted file mode 100644 index fac8605aa..000000000 --- a/examples/tutorials/10_async/10_temporal/020_state_machine/tests/test_agent.py +++ /dev/null @@ -1,193 +0,0 @@ -""" -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 deleted file mode 100644 index c4f7a8b4b..000000000 --- a/examples/tutorials/10_async/10_temporal/030_custom_activities/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 752ad8e93..000000000 --- a/examples/tutorials/10_async/10_temporal/030_custom_activities/Dockerfile +++ /dev/null @@ -1,59 +0,0 @@ -# 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 deleted file mode 100644 index 28a08c217..000000000 --- a/examples/tutorials/10_async/10_temporal/030_custom_activities/README.md +++ /dev/null @@ -1,106 +0,0 @@ -# [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 deleted file mode 100644 index b08063696..000000000 --- a/examples/tutorials/10_async/10_temporal/030_custom_activities/dev.ipynb +++ /dev/null @@ -1,228 +0,0 @@ -{ - "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 deleted file mode 100644 index 40af196f1..000000000 --- a/examples/tutorials/10_async/10_temporal/030_custom_activities/manifest.yaml +++ /dev/null @@ -1,138 +0,0 @@ -# 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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 819b119ce..000000000 --- a/examples/tutorials/10_async/10_temporal/030_custom_activities/project/acp.py +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index 36b5c9d2b..000000000 --- a/examples/tutorials/10_async/10_temporal/030_custom_activities/project/custom_activites.py +++ /dev/null @@ -1,111 +0,0 @@ -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 deleted file mode 100644 index 44ff5530a..000000000 --- a/examples/tutorials/10_async/10_temporal/030_custom_activities/project/run_worker.py +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index 2d894a9f4..000000000 --- a/examples/tutorials/10_async/10_temporal/030_custom_activities/project/shared_models.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 0fa85bbb9..000000000 --- a/examples/tutorials/10_async/10_temporal/030_custom_activities/project/workflow.py +++ /dev/null @@ -1,216 +0,0 @@ -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 deleted file mode 100644 index da04a8dab..000000000 --- a/examples/tutorials/10_async/10_temporal/030_custom_activities/project/workflow_utils.py +++ /dev/null @@ -1,204 +0,0 @@ -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 deleted file mode 100644 index cc53d065a..000000000 --- a/examples/tutorials/10_async/10_temporal/030_custom_activities/pyproject.toml +++ /dev/null @@ -1,42 +0,0 @@ -[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 deleted file mode 100644 index b839332c7..000000000 --- a/examples/tutorials/10_async/10_temporal/030_custom_activities/tests/test_agent.py +++ /dev/null @@ -1,136 +0,0 @@ -""" -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 deleted file mode 100644 index c2d7fca4d..000000000 --- a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index ef1ea0bf6..000000000 --- a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/Dockerfile +++ /dev/null @@ -1,59 +0,0 @@ -# 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 deleted file mode 100644 index b6e192b58..000000000 --- a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/README.md +++ /dev/null @@ -1,70 +0,0 @@ -# [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 deleted file mode 100644 index ab87b676d..000000000 --- a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/dev.ipynb +++ /dev/null @@ -1,1196 +0,0 @@ -{ - "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 deleted file mode 100644 index 3fe94a001..000000000 --- a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/manifest.yaml +++ /dev/null @@ -1,139 +0,0 @@ -# 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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 744068d77..000000000 --- a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/acp.py +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 636e99774..000000000 --- a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/run_worker.py +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index b54c8fade..000000000 --- a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/workflow.py +++ /dev/null @@ -1,481 +0,0 @@ -# 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 deleted file mode 100644 index d3815934f..000000000 --- a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/pyproject.toml +++ /dev/null @@ -1,34 +0,0 @@ -[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 deleted file mode 100644 index 1b1f7a400..000000000 --- a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/tests/test_agent.py +++ /dev/null @@ -1,136 +0,0 @@ -""" -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 deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index d38075e55..000000000 --- a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/Dockerfile +++ /dev/null @@ -1,62 +0,0 @@ -# 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 deleted file mode 100644 index 00b1fcea0..000000000 --- a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/README.md +++ /dev/null @@ -1,105 +0,0 @@ -# [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 deleted file mode 100644 index ae143b89f..000000000 --- a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/dev.ipynb +++ /dev/null @@ -1,124 +0,0 @@ -{ - "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 deleted file mode 100644 index f90511911..000000000 --- a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/environments.yaml +++ /dev/null @@ -1,64 +0,0 @@ -# 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 deleted file mode 100644 index b339542d5..000000000 --- a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/manifest.yaml +++ /dev/null @@ -1,140 +0,0 @@ -# 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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index fcdbba155..000000000 --- a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/project/acp.py +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index df281b586..000000000 --- a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/project/run_worker.py +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index e01f40ce6..000000000 --- a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/project/workflow.py +++ /dev/null @@ -1,313 +0,0 @@ -""" -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 deleted file mode 100644 index 5a1cd08d0..000000000 --- a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/pyproject.toml +++ /dev/null @@ -1,35 +0,0 @@ -[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", - "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 deleted file mode 100644 index 437a8f16c..000000000 --- a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/tests/test_agent.py +++ /dev/null @@ -1,132 +0,0 @@ -# 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 deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index d4b343603..000000000 --- a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/Dockerfile +++ /dev/null @@ -1,63 +0,0 @@ -# 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 deleted file mode 100644 index ea2c827a4..000000000 --- a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/README.md +++ /dev/null @@ -1,180 +0,0 @@ -# [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 deleted file mode 100644 index bcfc7182e..000000000 --- a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/dev.ipynb +++ /dev/null @@ -1,124 +0,0 @@ -{ - "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 deleted file mode 100644 index f90511911..000000000 --- a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/environments.yaml +++ /dev/null @@ -1,64 +0,0 @@ -# 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 deleted file mode 100644 index d28da57b6..000000000 --- a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/manifest.yaml +++ /dev/null @@ -1,139 +0,0 @@ -# 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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 3028093b9..000000000 --- a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/acp.py +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index 35ab678dc..000000000 --- a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/activities.py +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index 4aa50e182..000000000 --- a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/run_worker.py +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index 142bcc55c..000000000 --- a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/tools.py +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index 2204d3a05..000000000 --- a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/workflow.py +++ /dev/null @@ -1,358 +0,0 @@ -""" -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 deleted file mode 100644 index 22f4e0080..000000000 --- a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/pyproject.toml +++ /dev/null @@ -1,35 +0,0 @@ -[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", - "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 deleted file mode 100644 index e5f2982f9..000000000 --- a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/tests/test_agent.py +++ /dev/null @@ -1,158 +0,0 @@ -""" -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 deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index cc4c06bf6..000000000 --- a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/Dockerfile +++ /dev/null @@ -1,62 +0,0 @@ -# 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 deleted file mode 100644 index 8ba2b6781..000000000 --- a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/README.md +++ /dev/null @@ -1,199 +0,0 @@ -# [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 deleted file mode 100644 index 3e93e183c..000000000 --- a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/dev.ipynb +++ /dev/null @@ -1,124 +0,0 @@ -{ - "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 deleted file mode 100644 index f90511911..000000000 --- a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/environments.yaml +++ /dev/null @@ -1,64 +0,0 @@ -# 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 deleted file mode 100644 index f6fc7e9ca..000000000 --- a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/manifest.yaml +++ /dev/null @@ -1,140 +0,0 @@ -# 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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index c05effdbe..000000000 --- a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/acp.py +++ /dev/null @@ -1,95 +0,0 @@ -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 deleted file mode 100644 index 4cb056549..000000000 --- a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/activities.py +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index 3dc8520ab..000000000 --- a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/child_workflow.py +++ /dev/null @@ -1,68 +0,0 @@ -""" -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 deleted file mode 100644 index a07439fd4..000000000 --- a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/run_worker.py +++ /dev/null @@ -1,73 +0,0 @@ -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 deleted file mode 100644 index 92208ac4d..000000000 --- a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/tools.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -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 deleted file mode 100644 index 4f11ac4c0..000000000 --- a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/workflow.py +++ /dev/null @@ -1,248 +0,0 @@ -""" -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 deleted file mode 100644 index b38ee6e63..000000000 --- a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/pyproject.toml +++ /dev/null @@ -1,35 +0,0 @@ -[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", - "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 deleted file mode 100644 index 3a0386ffb..000000000 --- a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/tests/test_agent.py +++ /dev/null @@ -1,183 +0,0 @@ -""" -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 deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 4d50da2f0..000000000 --- a/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# 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 deleted file mode 100644 index 5428e814a..000000000 --- a/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/Dockerfile +++ /dev/null @@ -1,62 +0,0 @@ -# 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 deleted file mode 100644 index 2f40e53c1..000000000 --- a/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/README.md +++ /dev/null @@ -1,338 +0,0 @@ -# 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 deleted file mode 100644 index 2c1ce21dd..000000000 --- a/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/manifest.yaml +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index fdb08ded8..000000000 --- a/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/project/acp.py +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index a969cd760..000000000 --- a/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/project/run_worker.py +++ /dev/null @@ -1,85 +0,0 @@ -"""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 deleted file mode 100644 index c22045152..000000000 --- a/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/project/workflow.py +++ /dev/null @@ -1,240 +0,0 @@ -"""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 deleted file mode 100644 index 12573a90f..000000000 --- a/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/pyproject.toml +++ /dev/null @@ -1,35 +0,0 @@ -[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", - "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 deleted file mode 100644 index 9b93b1b76..000000000 --- a/examples/tutorials/10_async/10_temporal/090_claude_agents_sdk_mvp/tests/test_agent.py +++ /dev/null @@ -1,67 +0,0 @@ -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/100_gemini_litellm/.dockerignore b/examples/tutorials/10_async/10_temporal/100_gemini_litellm/.dockerignore deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/10_async/10_temporal/100_gemini_litellm/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index b1b52a9a7..000000000 --- a/examples/tutorials/10_async/10_temporal/100_gemini_litellm/Dockerfile +++ /dev/null @@ -1,54 +0,0 @@ -# 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 deleted file mode 100644 index b566fe2bd..000000000 --- a/examples/tutorials/10_async/10_temporal/100_gemini_litellm/README.md +++ /dev/null @@ -1,130 +0,0 @@ -# [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 deleted file mode 100644 index 8fca5e6e6..000000000 --- a/examples/tutorials/10_async/10_temporal/100_gemini_litellm/project/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# 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 deleted file mode 100644 index 9d2afdc37..000000000 --- a/examples/tutorials/10_async/10_temporal/100_gemini_litellm/project/acp.py +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index 7d9ac6516..000000000 --- a/examples/tutorials/10_async/10_temporal/100_gemini_litellm/project/run_worker.py +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index 249bdaa50..000000000 --- a/examples/tutorials/10_async/10_temporal/100_gemini_litellm/project/workflow.py +++ /dev/null @@ -1,234 +0,0 @@ -""" -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 deleted file mode 100644 index 9f0098e0b..000000000 --- a/examples/tutorials/10_async/10_temporal/100_gemini_litellm/pyproject.toml +++ /dev/null @@ -1,32 +0,0 @@ -[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 deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 17b0db8a0..000000000 --- a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/Dockerfile +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index b221c1238..000000000 --- a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/README.md +++ /dev/null @@ -1,153 +0,0 @@ -# Tutorial 110 (temporal): Pydantic AI Agent - -This tutorial demonstrates a **durable** Pydantic AI agent on AgentEx, backed by Temporal: -- Workflow state survives crashes mid-conversation (Temporal replay) -- Every LLM call and every tool call becomes its own Temporal activity (independent retries + observability) -- Streaming via Redis still works โ€” token-by-token deltas appear in the UI in real time - -This is the Temporal counterpart to the async base tutorial at [`10_async/00_base/110_pydantic_ai/`](../../00_base/110_pydantic_ai/). - -## Why Temporal? Why not just async? - -In async base 110, the agent state lives in memory inside the ACP process. If that process dies mid-LLM-call, the in-flight turn is lost. Temporal fixes this by: - -1. Recording every external interaction (LLM call, tool call) to a durable event log. -2. On worker restart, **replaying** the workflow code, using cached activity results to skip work that already finished. -3. Letting workflows live forever โ€” multi-day conversations or human-in-the-loop flows just work. - -## Architecture at a glance - -Two long-running processes plus shared infrastructure: - -``` -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ uvicorn project.acp:acp โ”‚ โ”‚ python -m run_worker โ”‚ -โ”‚ (HTTP shim, forwards โ”‚ โ”‚ (executes workflows + โ”‚ -โ”‚ signals to Temporal) โ”‚ โ”‚ activities) โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ โ”‚ - โ””โ”€โ”€โ”€โ”€โ–บ Temporal server โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - (event log + queue) - - Redis โ—„โ”€โ”€โ”€ activities push deltas - โ”‚ - โ””โ”€โ”€โ”€ Agentex API tails โ”€โ”€โ–บ UI client -``` - -The HTTP server is a thin shim that translates `task/event/send` into Temporal signals. The worker is where your agent code actually runs. Temporal sits in between, recording everything. - -## Key code patterns - -### `project/agent.py` โ€” wrap the base agent in `TemporalAgent` - -```python -base_agent = Agent(MODEL_NAME, deps_type=TaskDeps, system_prompt=...) -base_agent.tool_plain(get_weather) - -temporal_agent = TemporalAgent( - base_agent, - name="at110_pydantic_ai_agent", - event_stream_handler=event_handler, # streams to Redis from inside the model activity -) -``` - -`TemporalAgent` (from `pydantic_ai.durable_exec.temporal`) wraps a normal Pydantic AI Agent so that: -- Each LLM call runs in its own activity -- Each tool call runs in its own activity -- The wrapping is invisible to the workflow code that calls `temporal_agent.run(...)` - -### `project/workflow.py` โ€” declare `__pydantic_ai_agents__` - -```python -@workflow.defn(name=environment_variables.WORKFLOW_NAME) -class At110PydanticAiWorkflow(BaseWorkflow): - __pydantic_ai_agents__ = [temporal_agent] # โ† discovered by PydanticAIPlugin - - @workflow.signal(name=SignalName.RECEIVE_EVENT) - async def on_task_event_send(self, params): - await adk.messages.create(task_id=params.task.id, content=params.event.content) - result = await temporal_agent.run( - params.event.content.content, - deps=TaskDeps(task_id=params.task.id), - ) -``` - -The `__pydantic_ai_agents__` attribute is how `PydanticAIPlugin` discovers which activities to register on the worker โ€” no manual activity list needed. - -### `project/acp.py` โ€” no handlers, just plugin wiring - -```python -acp = FastACP.create( - acp_type="async", - config=TemporalACPConfig( - type="temporal", - temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), - plugins=[PydanticAIPlugin()], - ), -) -``` - -When `type="temporal"`, FastACP auto-wires HTTP โ†’ workflow signals. You don't define `@acp.on_task_event_send` anywhere โ€” Temporal handles it. - -### `project/run_worker.py` โ€” boot the worker with the plugin - -```python -worker = AgentexWorker( - task_queue=task_queue_name, - plugins=[PydanticAIPlugin()], -) -await worker.run( - activities=get_all_activities(), - workflow=At110PydanticAiWorkflow, -) -``` - -`get_all_activities()` returns the built-in Agentex activities (state, messages, streaming, tracing). Pydantic AI's per-agent activities are auto-added by the plugin. - -## Files - -| File | Purpose | -|------|---------| -| `project/acp.py` | Thin HTTP shim โ€” `FastACP.create(type="temporal", ...)` | -| `project/workflow.py` | `@workflow.defn` class with the signal handler | -| `project/agent.py` | Base Pydantic AI Agent wrapped in `TemporalAgent` | -| `project/tools.py` | Tool functions (must be `async` for Temporal compatibility) | -| `project/run_worker.py` | Worker boot script (separate process) | -| `tests/test_agent.py` | End-to-end test verifying tool round-trips | -| `manifest.yaml` | Sets `temporal.enabled: true` and declares workflow + queue name | - -## Running Locally - -You'll need three terminals open (this is the price of Temporal): - -```bash -# Terminal 1 โ€” backend services (separate repo) -cd ~/scale-agentex/agentex -make dev # brings up Temporal, Redis, Postgres, Agentex API - -# Terminal 2 โ€” this tutorial (ACP server + Temporal worker) -cd ~/scale-agentex-python/examples/tutorials/10_async/10_temporal/110_pydantic_ai -agentex agents run # this also launches the worker process - -# Terminal 3 โ€” tests -cd ~/scale-agentex-python/examples/tutorials/10_async/10_temporal/110_pydantic_ai -uv run pytest tests/test_agent.py -v -``` - -Watch the Temporal UI at http://localhost:8233 โ€” you'll see workflow executions, signal events, and one activity per LLM call + one per tool call. - -## Sync vs Async vs Temporal โ€” How the code differs - -| Concern | Sync (040) | Async base (110) | Temporal (this one) | -|---|---|---|---| -| `project/acp.py` | `@acp.on_message_send` yields events | `@acp.on_task_event_send` pushes to Redis | **No handlers** โ€” `FastACP.create(type="temporal", ...)` | -| Where the agent runs | In the ACP HTTP process | In the ACP HTTP process | In a separate worker process | -| Durability | Ephemeral โ€” request-scoped | Ephemeral โ€” process-scoped | **Durable** โ€” survives worker restarts via Temporal replay | -| Per-call retries | None | None | Each model + tool call automatically retried by Temporal | -| Code we add | โ€” | `acp.py` handler | `workflow.py`, `run_worker.py`, wrap agent in `TemporalAgent` | - -## Notes - -- Multi-turn conversation memory is not wired here. Workflow state (`self._turn_number`) is durable, but message history isn't currently threaded into `temporal_agent.run(..., message_history=...)`. To add: load via `adk.messages.list(task_id=...)` inside the signal handler and pass through. -- Reasoning/thinking tokens are not exercised by `gpt-4o-mini`. Swap to a reasoning-capable model to exercise that branch end-to-end. -- Tools must be `async` (Pydantic AI's Temporal integration requires it โ€” sync tools would run in threads, breaking Temporal's determinism guarantees). 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 deleted file mode 100644 index 15d00076f..000000000 --- a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/manifest.yaml +++ /dev/null @@ -1,64 +0,0 @@ -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 agent with tool calling and Redis streaming - - 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 - # env: - # OPENAI_BASE_URL: "https://your-litellm-proxy/v1" - -deployment: - image: - repository: "" - tag: "latest" - - global: - agent: - name: "at110-pydantic-ai" - description: "A Temporal-backed Pydantic AI agent" - 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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index dacb45ad6..000000000 --- a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/acp.py +++ /dev/null @@ -1,35 +0,0 @@ -"""ACP server for the Temporal Pydantic AI 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 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 deleted file mode 100644 index a33a317cc..000000000 --- a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/agent.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Pydantic AI agent definition for the Temporal tutorial. - -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 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 to Redis using our existing ``stream_pydantic_ai_events`` -helper. The ``task_id`` is 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.adk import ( - stream_pydantic_ai_events, - create_pydantic_ai_tracing_handler, -) - -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 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). - """ - tracing_handler = create_pydantic_ai_tracing_handler( - trace_id=run_context.deps.task_id, - parent_span_id=run_context.deps.parent_span_id, - task_id=run_context.deps.task_id, - ) - await stream_pydantic_ai_events( - events, - run_context.deps.task_id, - tracing_handler=tracing_handler, - ) - - -# 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="at110_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 deleted file mode 100644 index e54c9d1dc..000000000 --- a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/run_worker.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Temporal worker for the Pydantic AI 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 ``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 At110PydanticAiWorkflow -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=At110PydanticAiWorkflow, - ) - - -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 deleted file mode 100644 index 75640fcb7..000000000 --- a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/tools.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Tool definitions for the Temporal 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 in the -Temporal UI. - -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 deleted file mode 100644 index bb07ac818..000000000 --- a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/workflow.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Temporal workflow for the Pydantic AI tutorial. - -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 tutorial we don't need an -external ``adk.state`` round-trip โ€” the message list survives crashes -because Temporal replays activity results that produced it. -""" - -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 At110PydanticAiWorkflow(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() is the magic line. From the outside it - # looks like a regular async call. Internally it schedules: - # 1. A model activity (LLM HTTP call recorded by Temporal) - # 2. For each tool the model invokes, a tool activity - # 3. Each activity is retried, observable, and durable - # While the model activity runs, the event_stream_handler on - # temporal_agent pushes deltas to Redis so the UI sees tokens. - # - # 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( - 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 deleted file mode 100644 index 9f47733c0..000000000 --- a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/pyproject.toml +++ /dev/null @@ -1,38 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "at110-pydantic-ai" -version = "0.1.0" -description = "A Temporal-backed Pydantic AI agent with tool calling and Redis streaming" -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 deleted file mode 100644 index d01276ab8..000000000 --- a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/tests/test_agent.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Tests for the Temporal Pydantic AI agent. - -This test suite validates: -- The agent responds to a basic message -- Tool calls are visible in the message history (proving each tool call - ran as its own Temporal activity) - -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 Pydantic AI 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_local_sandbox/.dockerignore b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/.dockerignore deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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_local_sandbox/Dockerfile b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/Dockerfile deleted file mode 100644 index d4927d0ce..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/Dockerfile +++ /dev/null @@ -1,62 +0,0 @@ -# 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/120_openai_agents_local_sandbox/pyproject.toml /app/120_openai_agents_local_sandbox/pyproject.toml -COPY 10_async/10_temporal/120_openai_agents_local_sandbox/README.md /app/120_openai_agents_local_sandbox/README.md - -WORKDIR /app/120_openai_agents_local_sandbox - -# Copy the project code -COPY 10_async/10_temporal/120_openai_agents_local_sandbox/project /app/120_openai_agents_local_sandbox/project - -# Copy the test files -COPY 10_async/10_temporal/120_openai_agents_local_sandbox/tests /app/120_openai_agents_local_sandbox/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/120_openai_agents_local_sandbox - -ENV PYTHONPATH=/app - -# Set test environment variables -ENV AGENT_NAME=at120-openai-agents-local-sandbox - -# 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/120_openai_agents_local_sandbox/README.md b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/README.md deleted file mode 100644 index 161bc43da..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/README.md +++ /dev/null @@ -1,130 +0,0 @@ -# Tutorial 120: Temporal OpenAI Agents SDK with a Local Sandbox - -This tutorial demonstrates running an [OpenAI Agents SDK](https://developers.openai.com/api/docs/guides/agents) -`SandboxAgent` inside a **Temporal** workflow, backed by the **local** -(`unix_local`) sandbox. - -The agent is a "local sandbox assistant": it answers questions by actually running -real shell commands (e.g. `python3 --version`, `ls`, `python3 -c "..."`) instead of -guessing. Because it runs inside Temporal, the sandbox tool calls become durable, -retried, and observable activities. - -This mirrors the canonical OpenAI Agents SDK Temporal example -(`060_open_ai_agents_sdk_hello_world`) and the tools example -(`070_open_ai_agents_sdk_tools`). The new piece is the **Temporal sandbox bridge**. - -## Key Concepts - -### Temporal ACP -The Temporal ACP model (`acp_type: async`, `temporal.enabled: true`) maps task -lifecycle to a Temporal workflow: -- `@workflow.run` (`on_task_create`) keeps the conversation alive. -- `@workflow.signal(name=SignalName.RECEIVE_EVENT)` (`on_task_event_send`) handles - each user message. - -No ACP handlers are registered by hand โ€” the `TemporalACPConfig` wires them to the -workflow automatically. - -### Streaming (Interceptor + Model Provider + Hooks) -Real-time streaming uses STANDARD Temporal components โ€” no forked plugin: -- **`ContextInterceptor`** threads `task_id` through activity headers. The workflow - sets `self._task_id` so the interceptor can read it. -- **`TemporalStreamingModelProvider`** returns a model that streams tokens to Redis - in real time while still returning the complete response to Temporal for - determinism / replay safety. -- **`TemporalStreamingHooks`** creates the lifecycle messages (tool request / - response, etc.) in the database. - -The `stream_lifecycle_content` activity must be registered on the worker alongside -`get_all_activities()`. - -### The Temporal sandbox bridge (`UnixLocalSandboxClient`) -The sandbox client is registered ON THE WORKER (and the ACP) via the standard -plugin: - -```python -from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient -from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, SandboxClientProvider - -OpenAIAgentsPlugin( - model_provider=TemporalStreamingModelProvider(), - sandbox_clients=[SandboxClientProvider("local", UnixLocalSandboxClient())], -) -``` - -Inside the workflow, the run is pointed at that backend by name: - -```python -from temporalio.contrib.openai_agents.workflow import temporal_sandbox_client -from agents.sandbox import SandboxAgent, SandboxRunConfig -from agents.run_config import RunConfig -from agents.sandbox.snapshot import NoopSnapshotSpec -from agents.sandbox.capabilities import Shell -from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClientOptions - -agent = SandboxAgent( - name="Local Sandbox Assistant", - model="gpt-4o-mini", - instructions="...use the shell tools to actually run commands...", - capabilities=[Shell()], -) -run_config = RunConfig( - sandbox=SandboxRunConfig( - client=temporal_sandbox_client("local"), - options=UnixLocalSandboxClientOptions(), - snapshot=NoopSnapshotSpec(), # skip the per-turn workspace snapshot - ) -) -result = await Runner.run( - agent, self._state.input_list, run_config=run_config, - hooks=TemporalStreamingHooks(task_id=params.task.id), -) -``` - -`temporal_sandbox_client("local")` resolves the worker-registered client, so the -sandbox shell tool calls run as Temporal activities (durable + observable in the -Temporal UI). - -## Two important lessons - -1. **Don't double-post the assistant message.** The `TemporalStreamingModelProvider` - already streams AND persists the assistant's response. If you also call - `adk.messages.create(...)` after `Runner.run`, the answer shows up twice. We only - persist conversation state for the next turn via `result.to_input_list()`. -2. **Use `NoopSnapshotSpec()`.** Without it, the sandbox tries to take a per-turn - workspace snapshot, and stopping the sandbox can raise - `WorkspaceArchiveReadError`. `NoopSnapshotSpec()` skips that snapshot. - -## Files - -| File | Description | -|------|-------------| -| `project/acp.py` | Temporal ACP server (plugin + sandbox client + interceptor) | -| `project/run_worker.py` | Temporal worker (registers workflow, activities, plugin, sandbox client) | -| `project/workflow.py` | `BaseWorkflow` that runs the `SandboxAgent` against the local sandbox | -| `tests/test_agent.py` | Integration tests (polling pattern) | -| `manifest.yaml` | Agent configuration (temporal enabled) | -| `environments.yaml` | Per-environment deployment overrides | - -## Running Locally - -```bash -# From this directory -agentex agents run -``` - -Set `OPENAI_API_KEY` (or `LITELLM_API_KEY` if you're behind the Scale LiteLLM -gateway) in your environment or in a `.env` file in `project/` so the agent can call -the model. - -## Running Tests - -```bash -pytest tests/test_agent.py -v -``` - -## Further Reading - -- OpenAI Agents SDK guide: https://developers.openai.com/api/docs/guides/agents -- The async (non-Temporal) variant: `10_async/00_base/120_openai_agents_local_sandbox` -- The canonical OpenAI Agents SDK Temporal example: `10_async/10_temporal/060_open_ai_agents_sdk_hello_world` diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/environments.yaml b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/environments.yaml deleted file mode 100644 index f90511911..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/environments.yaml +++ /dev/null @@ -1,64 +0,0 @@ -# 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_local_sandbox/manifest.yaml b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/manifest.yaml deleted file mode 100644 index 86ac89288..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/manifest.yaml +++ /dev/null @@ -1,111 +0,0 @@ -# Agent Manifest Configuration -# --------------------------- -# This file defines how your agent should be built and deployed. - -# 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 - include_paths: - - 10_async/10_temporal/120_openai_agents_local_sandbox - - test_utils - - # Path to your agent's Dockerfile (relative to the root directory) - dockerfile: 10_async/10_temporal/120_openai_agents_local_sandbox/Dockerfile - - # Path to your agent's .dockerignore - dockerignore: 10_async/10_temporal/120_openai_agents_local_sandbox/.dockerignore - - -# Local Development Configuration -# ----------------------------- -local_development: - agent: - port: 8000 # Port where your local ACP server is running - host_address: host.docker.internal # Host address for Docker networking - - # File paths for local development (relative to this manifest.yaml) - paths: - # Path to ACP server file - acp: project/acp.py - # Path to temporal worker file - worker: project/run_worker.py - - -# Agent Configuration -# ----------------- -agent: - # Type of agent - either sync or async - acp_type: async - - # Unique name for your agent - name: at120-openai-agents-local-sandbox - - # Description of what your agent does - description: A Temporal OpenAI Agents SDK agent using a local (unix_local) sandbox - - # Temporal workflow configuration - temporal: - enabled: true - workflows: - # Name of the workflow class (must match the @workflow.defn name in workflow.py) - - name: at120-openai-agents-local-sandbox - - # Queue name for task distribution - queue_name: at120_openai_agents_local_sandbox_queue - - # Credentials mapping (maps Kubernetes secrets to environment variables) - 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 - - # Environment variables for running locally and for deployment - env: - OPENAI_AGENTS_DISABLE_TRACING: "1" - - -# Deployment Configuration -# ----------------------- -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 - global: - agent: - name: "at120-openai-agents-local-sandbox" - description: "A Temporal OpenAI Agents SDK agent using a local (unix_local) sandbox" - - # 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/120_openai_agents_local_sandbox/project/__init__.py b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/acp.py b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/acp.py deleted file mode 100644 index 196e1e7cd..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/acp.py +++ /dev/null @@ -1,83 +0,0 @@ -import os -import sys - -from temporalio.contrib.openai_agents import ( - OpenAIAgentsPlugin, - SandboxClientProvider, -) -from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient - -# === 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. We register the STANDARD OpenAIAgentsPlugin with: -# - the streaming model provider (real-time token streaming + persistence) -# - the LOCAL sandbox backend, registered under the name "local" so the -# workflow can resolve it via ``temporal_sandbox_client("local")`` -# plus the ContextInterceptor that threads task_id through activity headers. -acp = FastACP.create( - acp_type="async", - config=TemporalACPConfig( - # When deployed to the cluster, the Temporal address is set automatically. - # 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, - sandbox_clients=[ - SandboxClientProvider("local", UnixLocalSandboxClient()), - ], - ) - ], - interceptors=[context_interceptor], - ), -) - - -# Notice that we don't need to register any handlers when we use type="temporal". -# These handlers are automatically registered when the ACP is created: -# -# @acp.on_task_create -> the workflow method decorated with @workflow.run -# @acp.on_task_event_send -> the workflow method decorated with -# @workflow.signal(name=SignalName.RECEIVE_EVENT) -# @acp.on_task_cancel -> handled by the temporal client (cancels the workflow) diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/run_worker.py b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/run_worker.py deleted file mode 100644 index a2b7bdf6b..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/run_worker.py +++ /dev/null @@ -1,80 +0,0 @@ -import asyncio - -from temporalio.contrib.openai_agents import ( - OpenAIAgentsPlugin, - SandboxClientProvider, -) -from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient - -from project.workflow import At120OpenaiAgentsLocalSandboxWorkflow -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") - - # Register activities. ``stream_lifecycle_content`` powers the streaming - # lifecycle hooks; the rest are the standard AgentEx activities. - all_activities = get_all_activities() + [stream_lifecycle_content] - - # ============================================================================ - # STREAMING + SANDBOX SETUP - # ============================================================================ - # 1. ContextInterceptor threads task_id through activity headers so the - # streaming model + hooks know which task to stream/persist to. - # 2. TemporalStreamingModelProvider returns a model that streams tokens to - # Redis in real time while still returning the complete response to - # Temporal for determinism / replay safety. - # 3. SandboxClientProvider registers the LOCAL sandbox backend - # (UnixLocalSandboxClient) under the name "local". The workflow resolves - # it at run time via ``temporal_sandbox_client("local")``, so the sandbox - # tool calls run as durable Temporal activities. - # - # We use the STANDARD temporalio.contrib.openai_agents.OpenAIAgentsPlugin โ€” - # no forked plugin needed. - context_interceptor = ContextInterceptor() - temporal_streaming_model_provider = TemporalStreamingModelProvider() - - worker = AgentexWorker( - task_queue=task_queue_name, - plugins=[ - OpenAIAgentsPlugin( - model_provider=temporal_streaming_model_provider, - sandbox_clients=[ - SandboxClientProvider("local", UnixLocalSandboxClient()), - ], - ) - ], - interceptors=[context_interceptor], - ) - - await worker.run( - activities=all_activities, - workflow=At120OpenaiAgentsLocalSandboxWorkflow, - ) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/workflow.py b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/workflow.py deleted file mode 100644 index 45b61b04e..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/workflow.py +++ /dev/null @@ -1,213 +0,0 @@ -"""OpenAI Agents SDK + Temporal: Local Sandbox Tutorial - -This tutorial demonstrates running an OpenAI Agents SDK ``SandboxAgent`` inside a -Temporal workflow, backed by the **local** (``unix_local``) sandbox. The agent is -a "local sandbox assistant": it answers questions by actually running real shell -commands (e.g. ``python3 --version``, ``ls``, ``python3 -c "..."``) instead of -guessing. - -KEY CONCEPTS DEMONSTRATED: -- A ``SandboxAgent`` granted the ``Shell`` capability inside a durable Temporal - workflow. -- The Temporal sandbox bridge: ``temporal_sandbox_client("local")`` resolves to - the ``UnixLocalSandboxClient`` registered on the worker via - ``SandboxClientProvider`` (see ``run_worker.py`` / ``acp.py``). The sandbox tool - calls run as Temporal activities, so they are durable, retried, and observable. -- Real-time streaming + persistence via ``TemporalStreamingModelProvider`` + - ``ContextInterceptor`` (configured on the worker) and ``TemporalStreamingHooks``. - -IMPORTANT LESSONS (applied below): - (a) Do NOT post the assistant message yourself with ``adk.messages.create`` - after ``Runner.run``. The ``TemporalStreamingModelProvider`` already streams - and persists the assistant's response โ€” posting it again would duplicate the - answer in the UI. We only persist conversation state for the next turn via - ``result.to_input_list()``. - (b) Use ``NoopSnapshotSpec()`` so the per-turn workspace snapshot is skipped. - Without it, stopping the sandbox can raise ``WorkspaceArchiveReadError``. -""" - -from __future__ import annotations - -import os -import json - -from agents import 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, -) -from agentex.lib.core.temporal.plugins.openai_agents.hooks.hooks import ( - TemporalStreamingHooks, -) - -# OpenAI Agents SDK sandbox imports. These are safe to import at workflow module -# load time; the actual sandbox client is resolved at run time via -# ``temporal_sandbox_client`` (which maps to the worker-registered backend). -with workflow.unsafe.imports_passed_through(): - from agents.sandbox import SandboxAgent, SandboxRunConfig - from agents.run_config import RunConfig - from agents.sandbox.snapshot import NoopSnapshotSpec - from agents.sandbox.capabilities import Shell - from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClientOptions - from temporalio.contrib.openai_agents.workflow import temporal_sandbox_client - -# 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") - -logger = make_logger(__name__) - -MODEL_NAME = "gpt-4o-mini" -INSTRUCTIONS = """You are a local sandbox assistant. - -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. -""" - - -class StateModel(BaseModel): - """State model for preserving conversation history across turns.""" - - input_list: list = [] - turn_number: int = 0 - - -@workflow.defn(name=environment_variables.WORKFLOW_NAME) -class At120OpenaiAgentsLocalSandboxWorkflow(BaseWorkflow): - """Long-running Temporal workflow that runs a SandboxAgent against the local sandbox.""" - - 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: - logger.info(f"Received task event: {params.task.id}") - - if self._state is None: - raise ValueError("State is not initialized") - - self._state.turn_number += 1 - - # The ContextInterceptor reads ``self._task_id`` off the workflow - # instance and threads it through activity headers so the streaming - # model + hooks know which task to stream/persist to. - 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 so it shows up in the UI. - await adk.messages.create(task_id=params.task.id, content=params.event.content) - - 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 - - # Build the sandbox agent. The Shell capability becomes real shell - # tools backed by the sandbox client resolved at run time. - agent = SandboxAgent( - name="Local Sandbox Assistant", - model=MODEL_NAME, - instructions=INSTRUCTIONS, - capabilities=[Shell()], - ) - - # Point the run at the LOCAL sandbox backend registered on the worker - # under the name "local". ``temporal_sandbox_client`` resolves that - # registration so the sandbox tool calls execute as Temporal - # activities (durable + observable). - # - # IMPORTANT: ``NoopSnapshotSpec()`` skips the per-turn workspace - # snapshot โ€” otherwise stopping the sandbox can raise - # ``WorkspaceArchiveReadError``. - run_config = RunConfig( - sandbox=SandboxRunConfig( - client=temporal_sandbox_client("local"), - options=UnixLocalSandboxClientOptions(), - snapshot=NoopSnapshotSpec(), - ) - ) - - # TemporalStreamingHooks creates the lifecycle messages (tool - # request/response, etc.) and works with the streaming model - # provider to stream tokens to the UI in real time. - result = await Runner.run( - agent, - self._state.input_list, - run_config=run_config, - hooks=TemporalStreamingHooks(task_id=params.task.id), - max_turns=10, - ) - - # IMPORTANT: We do NOT post the assistant message ourselves here. - # The TemporalStreamingModelProvider already streamed and persisted - # the assistant's response. We only persist conversation state for - # the next turn. - self._state.input_list = result.to_input_list() - - if span: - span.output = self._state.model_dump() - - @workflow.run - async def on_task_create(self, params: CreateTaskParams) -> str: - logger.info(f"Task created: {params.task.id}") - - self._state = StateModel(input_list=[], turn_number=0) - - 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 run real shell commands in a local " - f"sandbox (backed by Temporal) to answer." - ), - ), - ) - - 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/120_openai_agents_local_sandbox/pyproject.toml b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/pyproject.toml deleted file mode 100644 index 696894e32..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/pyproject.toml +++ /dev/null @@ -1,36 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "at120_openai_agents_local_sandbox" -version = "0.1.0" -description = "A Temporal OpenAI Agents SDK agent using a local (unix_local) sandbox" -requires-python = ">=3.12" -dependencies = [ - "agentex-sdk>=0.6.0", - "openai-agents>=0.14.3,<0.15", - "temporalio>=1.18.2", - "scale-gp", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "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_local_sandbox/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/tests/test_agent.py deleted file mode 100644 index 5e161c061..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/tests/test_agent.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Tests for the Temporal OpenAI Agents SDK local-sandbox agent. - -This test suite validates that the agent actually runs shell commands in the -LOCAL sandbox (unix_local backend) via the Temporal sandbox bridge, by polling -for the agent's response: -- Ask for the Python version -> response contains "Python 3" -- Ask it to compute 21 * 2 with python3 -> response contains "42" - -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: at120-openai-agents-local-sandbox) -""" - -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", "at120-openai-agents-local-sandbox") - - -@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.") - - -async def _create_task_and_await_welcome(client: AsyncAgentex, agent_id: str) -> str: - """Create a task and wait for the workflow's welcome message; return the task id.""" - task_response = await client.agents.create_task( - agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex) - ) - task = task_response.result - assert task is not None - - welcome_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": - welcome_found = True - break - assert welcome_found, "Task creation (welcome) message not found" - return task.id - - -async def _send_and_collect_agent_text( - client: AsyncAgentex, agent_id: str, task_id: str, user_message: str -) -> str: - """Send a user message and accumulate the streamed agent text into a string.""" - 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, - 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 - if message.streaming_status == "DONE": - break - - assert final_message is not None, "Should have received an agent text message" - assert final_message.content is not None, "Final message should have content" - return final_message.content.content or "" - - -class TestLocalSandboxEvents: - """Test the Temporal local-sandbox OpenAI Agents SDK agent.""" - - @pytest.mark.asyncio - async def test_shell_python_version(self, client: AsyncAgentex, agent_id: str): - """The agent should run `python3 --version` in the local sandbox. - - The sandbox runs on Python 3.12, so the real output contains "Python 3". - """ - task_id = await _create_task_and_await_welcome(client, agent_id) - text = await _send_and_collect_agent_text( - client, - agent_id, - task_id, - "Use your shell to print the Python version on this machine, then " - "tell me what it is.", - ) - assert text, "Expected a non-empty response from the sandbox agent." - assert "Python 3" in text - - @pytest.mark.asyncio - async def test_shell_compute(self, client: AsyncAgentex, agent_id: str): - """The agent should use python3 in the sandbox to compute 21 * 2 == 42.""" - task_id = await _create_task_and_await_welcome(client, agent_id) - text = await _send_and_collect_agent_text( - client, - agent_id, - task_id, - "Use python3 in your shell to compute 21 * 2 and tell me the 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/examples/tutorials/10_async/10_temporal/130_langgraph/.dockerignore b/examples/tutorials/10_async/10_temporal/130_langgraph/.dockerignore deleted file mode 100644 index c4f7a8b4b..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# 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/130_langgraph/.env.example b/examples/tutorials/10_async/10_temporal/130_langgraph/.env.example deleted file mode 100644 index ab1a5790f..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/.env.example +++ /dev/null @@ -1,13 +0,0 @@ -# at130-langgraph - 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= \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/Dockerfile b/examples/tutorials/10_async/10_temporal/130_langgraph/Dockerfile deleted file mode 100644 index 8a125ac72..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/Dockerfile +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 61ccaf66a..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# at130-langgraph โ€” AgentEx Temporal + LangGraph - -A minimal Temporal-backed [LangGraph](https://langchain-ai.github.io/langgraph/) -agent. It uses the official [`temporalio.contrib.langgraph`](https://docs.temporal.io/develop/python/integrations/langgraph) -plugin so each LangGraph node runs as a durable **Temporal activity** (the LLM -`agent` node) or inline in the **workflow** (the `tools` node) โ€” set per node -with `execute_in`. *Temporal is the runtime; LangGraph is the agent framework.* - -> The Temporal LangGraph plugin is currently **experimental**. - -## The graph - -``` -START โ†’ agent โ†’ (tool calls?) โ†’ tools โ†’ agent - โ†’ (no tool calls?) โ†’ END -``` - -- `agent` (`execute_in="activity"`): the LLM call โ€” a retried, observable Temporal activity. -- `tools` (`execute_in="workflow"`): runs the tool calls inline in the workflow. - -The router and tools are `async` so LangGraph awaits them directly (a sync -callable is offloaded via `run_in_executor`, which Temporal workflows forbid). - -## Project structure - -``` -130_langgraph/ -โ”œโ”€โ”€ project/ -โ”‚ โ”œโ”€โ”€ acp.py # Thin async ACP server; registers the LangGraphPlugin -โ”‚ โ”œโ”€โ”€ workflow.py # Runs the graph each turn; keeps multi-turn memory -โ”‚ โ”œโ”€โ”€ graph.py # LangGraph graph; nodes tagged execute_in activity/workflow -โ”‚ โ””โ”€โ”€ tools.py # Async tool(s) -โ””โ”€โ”€ run_worker.py is project/run_worker.py -``` - -## Running - -```bash -agentex agents run --manifest manifest.yaml -``` - -Open the Temporal UI at http://localhost:8080 to watch the workflow and the -`agent` activity execute. Use `dev.ipynb` to create a task and send messages. - -## Adding tools - -Define an **async** `@tool` in `project/tools.py` and add it to `TOOLS`. The -model is bound with `TOOLS` and the tool node runs them by name. - -For a fuller version with human-in-the-loop approval and graph-introspection -queries, scaffold the `temporal-langgraph` template via `agentex init`. - -## Tests - -- `tests/test_graph_temporal.py` โ€” hermetic ReAct-loop test with a stub model, - plus a live end-to-end run through the real Temporal plugin (skipped unless - `LITELLM_API_KEY` is set). -- `tests/test_agent.py` โ€” live integration against a running agent. diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/dev.ipynb b/examples/tutorials/10_async/10_temporal/130_langgraph/dev.ipynb deleted file mode 100644 index 5320daac7..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/dev.ipynb +++ /dev/null @@ -1,126 +0,0 @@ -{ - "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 = \"at130-langgraph\"" - ] - }, - { - "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/130_langgraph/environments.yaml b/examples/tutorials/10_async/10_temporal/130_langgraph/environments.yaml deleted file mode 100644 index d54d8e5ff..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/environments.yaml +++ /dev/null @@ -1,64 +0,0 @@ -# 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-at130-langgraph" -# ********** 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/130_langgraph/manifest.yaml b/examples/tutorials/10_async/10_temporal/130_langgraph/manifest.yaml deleted file mode 100644 index d1f5960b1..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/manifest.yaml +++ /dev/null @@ -1,128 +0,0 @@ -# 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: - # Build from the tutorials root so shared test_utils are available. - 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 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: at130-langgraph - - # Description of what your agent does - # Helps with documentation and discovery - description: "A Temporal-backed LangGraph agent whose nodes run as Temporal 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: at130-langgraph - - # Queue name for task distribution - # Used by Temporal to route tasks to your agent - # Convention: _task_queue - queue_name: at130_langgraph_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: 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/examples/tutorials/10_async/10_temporal/130_langgraph/project/__init__.py b/examples/tutorials/10_async/10_temporal/130_langgraph/project/__init__.py deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index c01f8831c..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/project/acp.py +++ /dev/null @@ -1,42 +0,0 @@ -"""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/examples/tutorials/10_async/10_temporal/130_langgraph/project/graph.py b/examples/tutorials/10_async/10_temporal/130_langgraph/project/graph.py deleted file mode 100644 index 8d8de92d1..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/project/graph.py +++ /dev/null @@ -1,79 +0,0 @@ -"""LangGraph graph for at130-langgraph โ€” nodes run as Temporal activities. - -The ``temporalio.contrib.langgraph`` plugin runs each node where its -``execute_in`` metadata says: the LLM ``agent`` node as a durable Temporal -**activity**, the ``tools`` node inline in the **workflow**. - - START โ†’ agent โ†’ (tool calls?) โ†’ tools โ†’ agent - โ†’ (no tool calls?) โ†’ END - -The router and tools are ``async`` so LangGraph awaits them directly โ€” a sync -callable would be offloaded via ``run_in_executor``, which Temporal's workflow -event loop does not support. -""" - -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 langgraph.prebuilt import ToolNode -from langchain_core.messages import SystemMessage -from langgraph.graph.message import add_messages - -from project.tools import TOOLS - -# Name this graph is registered under in the LangGraphPlugin (acp.py / run_worker.py). -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 route_after_agent(state: AgentState) -> str: - """Go to the tools node if the model requested tools, else finish (async router).""" - 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", ToolNode(TOOLS), 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 deleted file mode 100644 index 7040f560b..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/project/run_worker.py +++ /dev/null @@ -1,50 +0,0 @@ -"""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 โ€” 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 At130LanggraphWorkflow -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=At130LanggraphWorkflow, - ) - - -if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file 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 deleted file mode 100644 index 20b7185ee..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/project/tools.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Tools for the LangGraph agent. - -Tools are ``async`` so the in-workflow tool node can await them directly -(a sync tool would be offloaded via ``run_in_executor``, which Temporal's -workflow event loop does not allow). -""" - -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.""" - # TODO: replace with a real weather API call. - return f"The weather in {city} is sunny and 72ยฐF" - - -TOOLS = [get_weather] 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 deleted file mode 100644 index a50670251..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/project/workflow.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Temporal workflow for at130-langgraph โ€” Temporal as the LangGraph runtime. - -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 At130LanggraphWorkflow(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"] - - # Surface the messages this turn produced (tool calls, results, final - # text) to the AgentEx UI. The SDK helper does the LangGraphโ†’AgentEx - # message conversion. - 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 deleted file mode 100644 index e22905de4..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/pyproject.toml +++ /dev/null @@ -1,42 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "at130-langgraph" -version = "0.1.0" -description = "A Temporal-backed LangGraph agent whose nodes run as Temporal activities" -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 \ No newline at end of file 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 deleted file mode 100644 index b798f568f..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/tests/test_agent.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Integration tests for the Temporal + 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. - -For fast, network-free coverage of the graph + human-in-the-loop logic, see -``test_graph_temporal.py``. - -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 - - # 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 (LangGraph node, as a Temporal activity) - # 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? 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 - # get_weather 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/130_langgraph/tests/test_graph_temporal.py b/examples/tutorials/10_async/10_temporal/130_langgraph/tests/test_graph_temporal.py deleted file mode 100644 index 485b896f6..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/tests/test_graph_temporal.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Tests for the Temporal + LangGraph agent's graph. - -Two layers: - -1. ``TestGraphLogic`` โ€” hermetic, no network. Compiles the actual shipped - graph (``project/graph.py``) with a deterministic stub model and runs the - ReAct loop (agent โ†’ tools โ†’ agent) to completion. - -2. ``TestTemporalPlugin`` โ€” end-to-end through the real Temporal LangGraph - plugin on a local Temporal server, proving the LLM node runs as an activity - and the tool node in the workflow. Needs a real model, so it is skipped - unless ``LITELLM_API_KEY`` (or ``OPENAI_API_KEY``) is set. - -Run from the agent's own (uv) environment: pytest tests/test_graph_temporal.py -v -""" - -from __future__ import annotations - -import os -import uuid - -import pytest - -pytest.importorskip("langgraph") -pytest.importorskip("temporalio.contrib.langgraph") - -import project.graph as graph_module -from temporalio import workflow -from project.graph import GRAPH_NAME, build_graph -from langchain_core.messages import AIMessage, ToolMessage -from temporalio.contrib.langgraph import graph as lg_graph - - -@workflow.defn -class _DriverWorkflow: - """Module-level driver workflow (Temporal forbids local workflow classes).""" - - @workflow.run - async def run(self, message: str) -> str: - compiled = lg_graph(GRAPH_NAME).compile() - result = await compiled.ainvoke({"messages": [{"role": "user", "content": message}]}) - return result["messages"][-1].content - - -class _StubModel: - """Deterministic stand-in for ``ChatOpenAI(...).bind_tools(...)``. - - First call โ†’ emit a tool call for ``get_weather``; once a ToolMessage is in - the history โ†’ emit a plain text answer. Drives the full ReAct loop offline. - """ - - def bind_tools(self, _tools): - return self - - async def ainvoke(self, messages): - if any(isinstance(m, ToolMessage) for m in messages): - return AIMessage(content="All done โ€” the tool has run.") - return AIMessage( - content="", - tool_calls=[{"id": "call_1", "name": "get_weather", "args": {"city": "Denver"}}], - ) - - -class TestGraphLogic: - """Hermetic test of the ReAct loop, no network.""" - - @pytest.mark.asyncio - async def test_react_loop_runs_tool(self, monkeypatch): - monkeypatch.setattr(graph_module, "ChatOpenAI", lambda *_a, **_k: _StubModel()) - compiled = build_graph().compile() - result = await compiled.ainvoke({"messages": [{"role": "user", "content": "go"}]}) - - tool_outputs = [m.content for m in result["messages"] if isinstance(m, ToolMessage)] - assert any("sunny" in o for o in tool_outputs) - assert "done" in result["messages"][-1].content.lower() - - -@pytest.mark.skipif( - not (os.environ.get("LITELLM_API_KEY") or os.environ.get("OPENAI_API_KEY")), - reason="needs a real model (set LITELLM_API_KEY) for the live Temporal run", -) -class TestTemporalPlugin: - """End-to-end through the real Temporal LangGraph plugin on a local server.""" - - @pytest.mark.asyncio - async def test_nodes_run_as_activities_via_plugin(self): - from temporalio.worker import Worker, UnsandboxedWorkflowRunner - from temporalio.testing import WorkflowEnvironment - from temporalio.contrib.langgraph import LangGraphPlugin - - plugin = LangGraphPlugin(graphs={GRAPH_NAME: build_graph()}) - async with await WorkflowEnvironment.start_local(plugins=[plugin]) as env: - async with Worker( - env.client, - task_queue="tq", - workflows=[_DriverWorkflow], - workflow_runner=UnsandboxedWorkflowRunner(), - ): - out = await env.client.execute_workflow( - _DriverWorkflow.run, - "What's the weather in Denver? Use the get_weather tool.", - id=f"wf-{uuid.uuid4()}", - task_queue="tq", - ) - assert "denver" in out.lower() diff --git a/examples/tutorials/README.md b/examples/tutorials/README.md deleted file mode 100644 index c92316858..000000000 --- a/examples/tutorials/README.md +++ /dev/null @@ -1,155 +0,0 @@ -# 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 deleted file mode 100644 index de8fcf66b..000000000 --- a/examples/tutorials/TEST_RUNNER_README.md +++ /dev/null @@ -1,142 +0,0 @@ -# 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 deleted file mode 100644 index 7be1a0764..000000000 --- a/examples/tutorials/pytest.ini +++ /dev/null @@ -1,4 +0,0 @@ -[pytest] -pythonpath = . -testpaths = . -addopts = --import-mode=importlib diff --git a/examples/tutorials/run_agent_test.sh b/examples/tutorials/run_agent_test.sh deleted file mode 100755 index c6fd17960..000000000 --- a/examples/tutorials/run_agent_test.sh +++ /dev/null @@ -1,448 +0,0 @@ -#!/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 - - # Use wheel from dist directory at repo root - local wheel_file=$(ls /home/runner/work/*/*/dist/agentex_sdk-*.whl 2>/dev/null | head -n1) - if [[ -z "$wheel_file" ]]; then - echo -e "${RED}โŒ No built wheel found in dist/agentex_sdk-*.whl${NC}" - echo -e "${YELLOW}๐Ÿ’ก Please build the local SDK first by running: uv build${NC}" - echo -e "${YELLOW}๐Ÿ’ก From the repo root directory${NC}" - cd "$original_dir" - return 1 - fi - - # Use the built wheel - uv run --with "$wheel_file" 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 wheel_file - wheel_file=$(ls /home/runner/work/*/*/dist/agentex_sdk-*.whl 2>/dev/null | head -n1) - if [[ -z "$wheel_file" ]]; then - wheel_file=$(ls "${SCRIPT_DIR}/../../dist/agentex_sdk-*.whl" 2>/dev/null | head -n1) - fi - if [[ -n "$wheel_file" ]]; then - pytest_cmd=("uv" "run" "--with" "$wheel_file" "--with" "pytest" "--with" "pytest-asyncio" "pytest") - fi - 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 built wheel is 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 - } - - # Check if wheel exists in dist directory at repo root - local wheel_file=$(ls /home/runner/work/*/*/dist/agentex_sdk-*.whl 2>/dev/null | head -n1) - if [[ -z "$wheel_file" ]]; then - echo -e "${RED}โŒ No built wheel found in dist/agentex_sdk-*.whl${NC}" - echo -e "${YELLOW}๐Ÿ’ก Please build the local SDK first by running: uv build${NC}" - echo -e "${YELLOW}๐Ÿ’ก From the repo root directory${NC}" - cd "$original_dir" - return 1 - fi - - # Test the wheel by running agentex --help - if ! uv run --with "$wheel_file" agentex --help >/dev/null 2>&1; then - echo -e "${RED}โŒ Failed to run agentex with built wheel${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 deleted file mode 100644 index 2187e98d8..000000000 --- a/examples/tutorials/test_utils/async_utils.py +++ /dev/null @@ -1,286 +0,0 @@ -""" -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 deleted file mode 100644 index 808ee0af1..000000000 --- a/examples/tutorials/test_utils/sync.py +++ /dev/null @@ -1,95 +0,0 @@ -""" -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 index 13b6c016c..e5791cc5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,52 +9,24 @@ authors = [ ] 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", - "typer>=0.16,<0.17", - "questionary>=2.0.1,<3", - "rich>=13.9.2,<14", - "fastapi>=0.115.0", - "starlette>=0.49.1", - "uvicorn>=0.31.1", - "watchfiles>=0.24.0,<1.0", - "python-on-whales>=0.73.0,<0.74", - "pyyaml>=6.0.2,<7", - "jsonschema>=4.23.0,<5", - "jsonref>=1.1.0,<2", - "temporalio>=1.26.0,<2", - "aiohttp>=3.10.10,<4", - "redis>=5.2.0,<8", - "litellm>=1.83.7,<2", - "kubernetes>=25.0.0,<36.0.0", - "jinja2>=3.1.3,<4", - "mcp>=1.4.1", - "scale-gp>=0.1.0a59", - "openai-agents>=0.14.3,<0.15", - "pydantic-ai-slim>=1.0,<2", - "json_log_formatter>=1.1.1", - "scale-gp-beta>=0.2.0", - "openai>=2.2,<3", # Required by openai-agents; litellm now supports openai 2.x (issue #13711 resolved: https://github.com/BerriAI/litellm/issues/13711) - "cloudpickle>=3.1.1", - "ddtrace>=3.13.0", - "yaspin>=3.1.0", - "claude-agent-sdk>=0.1.0", - "langgraph-checkpoint>=2.0.0", - "opentelemetry-sdk>=1.20.0", - "opentelemetry-api>=1.20.0", + "httpx>=0.23.0, <1", + "pydantic>=1.9.0, <3", + "typing-extensions>=4.14, <5", + "anyio>=3.5.0, <5", + "distro>=1.7.0, <2", + "sniffio", ] -requires-python = ">= 3.11,<4" +requires-python = ">= 3.9" classifiers = [ "Typing :: Typed", "Intended Audience :: Developers", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: MacOS", @@ -70,16 +42,16 @@ Repository = "https://github.com/scaleapi/scale-agentex-python" [project.optional-dependencies] aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.9"] -dev = [ - "ruff>=0.3.4", -] - -[project.scripts] -agentex = "agentex.lib.cli.commands.main:app" [tool.uv] managed = true required-version = ">=0.9" +conflicts = [ + [ + { group = "pydantic-v1" }, + { group = "pydantic-v2" }, + ], +] [dependency-groups] # version pins are in uv.lock @@ -94,12 +66,14 @@ dev = [ "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", +] +pydantic-v1 = [ + "pydantic>=1.9.0,<2", +] +pydantic-v2 = [ + "pydantic~=2.0 ; python_full_version < '3.14'", + "pydantic~=2.12 ; python_full_version >= '3.14'", ] [build-system] @@ -113,14 +87,6 @@ include = [ [tool.hatch.build.targets.wheel] packages = ["src/agentex"] -# Don't ship internal test files in the wheel. `lib/cli/templates/**/test_agent.py.j2` -# is intentionally kept โ€” those render into user projects. -exclude = [ - "src/agentex/lib/**/tests/**", - "src/agentex/lib/**/test_*.py", - "src/agentex/lib/**/conftest.py", - "src/agentex/lib/**/pytest.ini", -] [tool.hatch.build.targets.sdist] # Basically everything except hidden files/directories (such as .github, .devcontainers, .python-version, etc) @@ -155,25 +121,21 @@ xfail_strict = true asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" filterwarnings = [ - "error", - "ignore::pydantic.warnings.PydanticDeprecatedSince20", + "error" ] [tool.pyright] -# Default to basic type checking, but override for specific directories -typeCheckingMode = "basic" -pythonVersion = "3.12" +# this enables practically every flag given by pyright. +# there are a couple of flags that are still disabled by +# default in strict mode as they are experimental and niche. +typeCheckingMode = "strict" +pythonVersion = "3.9" exclude = [ "_dev", ".venv", ".nox", ".git", - "agentex-server", - "examples/tutorials", - # Exclude autogenerated Stainless code from type checking - "src/agentex/resources", - "src/agentex/types", ] reportImplicitOverride = true @@ -182,33 +144,6 @@ 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 @@ -219,7 +154,7 @@ show_error_codes = true # # 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/.*'] +exclude = ["src/agentex/_files.py", "_dev/.*.py", "tests/.*"] strict_equality = true implicit_reexport = true @@ -236,7 +171,7 @@ warn_unused_ignores = false warn_redundant_casts = false disallow_any_generics = true -# disallow_untyped_defs = true +disallow_untyped_defs = true disallow_untyped_calls = true disallow_subclassing_any = true disallow_incomplete_defs = true @@ -252,7 +187,7 @@ cache_fine_grained = true # ``` # 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" +disable_error_code = "func-returns-value,overload-cannot-match" # https://github.com/python/mypy/issues/12162 [[tool.mypy.overrides]] @@ -314,16 +249,7 @@ 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"] +"tests/**.py" = ["T201", "T203"] "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/requirements-dev.lock b/requirements-dev.lock index 3c4550899..cb221623f 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -1,511 +1,110 @@ # This file was autogenerated by uv via the following command: -# uv export --no-hashes -o requirements-dev.lock +# uv export -o requirements-dev.lock --no-hashes -e . -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-sdk - # claude-agent-sdk # httpx - # httpx2 - # mcp - # openai - # scale-gp - # scale-gp-beta - # sse-starlette - # starlette - # watchfiles -asttokens==3.0.1 - # via stack-data -async-timeout==5.0.1 ; python_full_version < '3.11.3' - # via redis -attrs==25.4.0 - # via - # aiohttp - # jsonschema - # referencing -bytecode==0.17.0 - # via ddtrace +backports-asyncio-runner==1.2.0 ; python_full_version < '3.11' + # via pytest-asyncio 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 + # via pytest dirty-equals==0.11 distro==1.9.0 + # via agentex-sdk +exceptiongroup==1.3.1 ; python_full_version < '3.11' # via - # agentex-sdk - # openai - # scale-gp - # scale-gp-beta -durationpy==0.10 - # via kubernetes -envier==0.6.1 - # via ddtrace + # anyio + # pytest 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 + # via httpcore httpcore==1.0.9 # via httpx -httpcore2==2.3.0 - # via httpx2 httpx==0.28.1 # via # agentex-sdk - # 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 +iniconfig==2.1.0 ; python_full_version < '3.10' # 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 +iniconfig==2.3.0 ; python_full_version >= '3.10' + # via pytest +markdown-it-py==3.0.0 ; python_full_version < '3.10' + # via rich +markdown-it-py==4.0.0 ; python_full_version >= '3.10' # 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 + # via pytest 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-sdk - # 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 +pytest==8.4.2 ; python_full_version < '3.10' # 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 +pytest==9.0.2 ; python_full_version >= '3.10' # 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 + # pytest-asyncio + # pytest-xdist +pytest-asyncio==1.2.0 ; python_full_version < '3.10' +pytest-asyncio==1.3.0 ; python_full_version >= '3.10' +pytest-xdist==3.8.0 +python-dateutil==2.9.0.post0 ; python_full_version < '3.10' + # via time-machine respx==0.22.0 -rich==13.9.4 - # via - # agentex-sdk - # typer -rpds-py==2026.5.1 - # via - # jsonschema - # referencing +rich==14.2.0 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 +six==1.17.0 ; python_full_version < '3.10' + # via python-dateutil sniffio==1.3.1 - # via - # agentex-sdk - # 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 +time-machine==2.19.0 ; python_full_version < '3.10' +time-machine==3.2.0 ; python_full_version >= '3.10' +tomli==2.4.0 ; python_full_version < '3.11' # 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 + # mypy + # pytest typing-extensions==4.15.0 # via # agentex-sdk - # aiosignal # anyio - # fastapi - # huggingface-hub - # ipython - # langchain-core - # langchain-protocol - # mcp + # exceptiongroup # 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 + # via pydantic zipp==3.23.0 # via importlib-metadata -zstandard==0.25.0 - # via langsmith diff --git a/scripts/lint b/scripts/lint index 11b4f3586..417ca143c 100755 --- a/scripts/lint +++ b/scripts/lint @@ -15,5 +15,8 @@ fi echo "==> Running pyright" uv run pyright +echo "==> Running mypy" +uv run mypy . + echo "==> Making sure it imports" uv run python -c 'import agentex' diff --git a/scripts/test b/scripts/test index fbda925f5..fe50ebb13 100755 --- a/scripts/test +++ b/scripts/test @@ -10,12 +10,18 @@ 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" +PY_VERSION_MIN=">=3.9.0" +PY_VERSION_MAX=">=3.14.0" function run_tests() { echo "==> Running tests with Pydantic v2" uv run --isolated --all-extras pytest "$@" + + # Skip Pydantic v1 tests on latest Python (not supported) + if [[ "$UV_PYTHON" != "$PY_VERSION_MAX" ]]; then + echo "==> Running tests with Pydantic v1" + uv run --isolated --all-extras --group=pydantic-v1 pytest "$@" + fi } # If UV_PYTHON is already set in the environment, just run the command once diff --git a/src/agentex/_constants.py b/src/agentex/_constants.py index ccb3ec52f..6ddf2c717 100644 --- a/src/agentex/_constants.py +++ b/src/agentex/_constants.py @@ -6,9 +6,9 @@ OVERRIDE_CAST_TO_HEADER = "____stainless_override_cast_to" # default timeout is 1 minute -DEFAULT_TIMEOUT = httpx.Timeout(timeout=300, connect=5.0) +DEFAULT_TIMEOUT = httpx.Timeout(timeout=60, connect=5.0) DEFAULT_MAX_RETRIES = 2 -DEFAULT_CONNECTION_LIMITS = httpx.Limits(max_connections=1000, max_keepalive_connections=1000) +DEFAULT_CONNECTION_LIMITS = httpx.Limits(max_connections=100, max_keepalive_connections=20) INITIAL_RETRY_DELAY = 0.5 MAX_RETRY_DELAY = 8.0 diff --git a/src/agentex/_utils/_typing.py b/src/agentex/_utils/_typing.py index e548aa2df..193109f3a 100644 --- a/src/agentex/_utils/_typing.py +++ b/src/agentex/_utils/_typing.py @@ -53,9 +53,7 @@ def is_typevar(typ: type) -> bool: _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] + _TYPE_ALIAS_TYPES = (*_TYPE_ALIAS_TYPES, typing.TypeAliasType) def is_type_alias_type(tp: Any, /) -> TypeIs[typing_extensions.TypeAliasType]: diff --git a/src/agentex/lib/__init__.py b/src/agentex/lib/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/adk/__init__.py b/src/agentex/lib/adk/__init__.py deleted file mode 100644 index a08131260..000000000 --- a/src/agentex/lib/adk/__init__.py +++ /dev/null @@ -1,61 +0,0 @@ -# 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_tracing import create_langgraph_tracing_handler -from agentex.lib.adk._modules._langgraph_async import stream_langgraph_events -from agentex.lib.adk._modules._langgraph_messages import emit_langgraph_messages -from agentex.lib.adk._modules._langgraph_sync import convert_langgraph_to_agentex_events -from agentex.lib.adk._modules._pydantic_ai_async import stream_pydantic_ai_events -from agentex.lib.adk._modules._pydantic_ai_sync import convert_pydantic_ai_to_agentex_events -from agentex.lib.adk._modules._pydantic_ai_tracing import create_pydantic_ai_tracing_handler -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 - -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", - # Checkpointing / LangGraph - "create_checkpointer", - "create_langgraph_tracing_handler", - "stream_langgraph_events", - "emit_langgraph_messages", - "convert_langgraph_to_agentex_events", - # Pydantic AI - "stream_pydantic_ai_events", - "convert_pydantic_ai_to_agentex_events", - "create_pydantic_ai_tracing_handler", - # Providers - "providers", - # Utils - "utils", -] diff --git a/src/agentex/lib/adk/_modules/__init__.py b/src/agentex/lib/adk/_modules/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/adk/_modules/_http_checkpointer.py b/src/agentex/lib/adk/_modules/_http_checkpointer.py deleted file mode 100644 index ce37cc5f2..000000000 --- a/src/agentex/lib/adk/_modules/_http_checkpointer.py +++ /dev/null @@ -1,380 +0,0 @@ -"""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_async.py b/src/agentex/lib/adk/_modules/_langgraph_async.py deleted file mode 100644 index 3e61c42f9..000000000 --- a/src/agentex/lib/adk/_modules/_langgraph_async.py +++ /dev/null @@ -1,202 +0,0 @@ -"""Async LangGraph streaming helper for Agentex. - -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. -""" - - -async def stream_langgraph_events(stream, task_id: str) -> str: - """Stream LangGraph events to Agentex via Redis. - - 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). - - 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. - """ - # Lazy imports so langgraph/langchain aren't required at module load time - from langchain_core.messages import ToolMessage, AIMessageChunk - - from agentex.lib import adk - 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_summary_delta import ReasoningSummaryDelta - - text_context = None - reasoning_context = None - final_text = "" - - try: - 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): - if reasoning_context: - await reasoning_context.close() - reasoning_context = None - - if not text_context: - final_text = "" - text_context = await adk.streaming.streaming_task_message_context( - task_id=task_id, - initial_content=TextContent( - author="agent", - content="", - format="markdown", - ), - ).__aenter__() - - final_text += chunk.content - await text_context.stream_update( - StreamTaskMessageDelta( - parent_task_message=text_context.task_message, - delta=TextDelta(type="text", text_delta=chunk.content), - type="delta", - ) - ) - - # ---------------------------------------------------------- - # 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": - reasoning_text = "" - for s in block.get("summary", []): - if isinstance(s, dict) and s.get("type") == "summary_text": - reasoning_text += s.get("text", "") - if not reasoning_text: - continue - - if text_context: - await text_context.close() - text_context = None - - if not reasoning_context: - reasoning_context = await adk.streaming.streaming_task_message_context( - task_id=task_id, - initial_content=ReasoningContent( - author="agent", - summary=[], - content=[], - type="reasoning", - style="active", - ), - ).__aenter__() - - await reasoning_context.stream_update( - StreamTaskMessageDelta( - parent_task_message=reasoning_context.task_message, - delta=ReasoningSummaryDelta( - type="reasoning_summary", - summary_index=0, - summary_delta=reasoning_text, - ), - type="delta", - ) - ) - - elif block_type == "text": - text_delta = block.get("text", "") - if not text_delta: - continue - - if reasoning_context: - await reasoning_context.close() - reasoning_context = None - - if not text_context: - final_text = "" - text_context = await adk.streaming.streaming_task_message_context( - task_id=task_id, - initial_content=TextContent( - author="agent", - content="", - format="markdown", - ), - ).__aenter__() - - final_text += text_delta - await text_context.stream_update( - StreamTaskMessageDelta( - parent_task_message=text_context.task_message, - delta=TextDelta(type="text", text_delta=text_delta), - type="delta", - ) - ) - - 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: - if text_context: - await text_context.close() - text_context = None - if reasoning_context: - await reasoning_context.close() - reasoning_context = None - - if hasattr(msg, "tool_calls") and msg.tool_calls: - for tc in msg.tool_calls: - await adk.messages.create( - task_id=task_id, - content=ToolRequestContent( - tool_call_id=tc["id"], - name=tc["name"], - arguments=tc["args"], - author="agent", - ), - ) - - elif node_name == "tools": - messages = state_update.get("messages", []) - for msg in messages: - if isinstance(msg, ToolMessage): - await adk.messages.create( - task_id=task_id, - 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", - ), - ) - finally: - # Always close open contexts - if text_context: - await text_context.close() - if reasoning_context: - await reasoning_context.close() - - return final_text diff --git a/src/agentex/lib/adk/_modules/_langgraph_messages.py b/src/agentex/lib/adk/_modules/_langgraph_messages.py deleted file mode 100644 index c8856755b..000000000 --- a/src/agentex/lib/adk/_modules/_langgraph_messages.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Emit finished LangGraph messages as Agentex task 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. -""" - -from __future__ import annotations - -from typing import Any - - -async def emit_langgraph_messages(messages: list[Any], task_id: str) -> str: - """Create Agentex messages for a list of LangGraph messages. - - 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_sync.py b/src/agentex/lib/adk/_modules/_langgraph_sync.py deleted file mode 100644 index 6d4ce715f..000000000 --- a/src/agentex/lib/adk/_modules/_langgraph_sync.py +++ /dev/null @@ -1,228 +0,0 @@ -"""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. -""" - - -async def convert_langgraph_to_agentex_events(stream): - """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). - - Args: - stream: Async iterator from graph.astream(..., stream_mode=["messages", "updates"]) - - 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.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=TextContent(type="text", author="agent", content=""), - ) - 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 - - 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) diff --git a/src/agentex/lib/adk/_modules/_langgraph_tracing.py b/src/agentex/lib/adk/_modules/_langgraph_tracing.py deleted file mode 100644 index 74b8dcb57..000000000 --- a/src/agentex/lib/adk/_modules/_langgraph_tracing.py +++ /dev/null @@ -1,244 +0,0 @@ -"""LangChain callback handler that creates Agentex spans for LLM calls and tool executions.""" -# ruff: noqa: ARG002 -# Callback methods must accept all arguments defined by LangChain's AsyncCallbackHandler interface. - -from __future__ import annotations - -from uuid import UUID -from typing import Any, override - -from langchain_core.outputs import LLMResult -from langchain_core.messages import BaseMessage -from langchain_core.callbacks import AsyncCallbackHandler - -from agentex.types.span import Span -from agentex.lib.utils.logging import make_logger -from agentex.lib.adk._modules.tracing import TracingModule - -logger = make_logger(__name__) - - -class AgentexLangGraphTracingHandler(AsyncCallbackHandler): - """Async LangChain callback handler that records Agentex tracing spans. - - Creates child spans under a parent span for each LLM call and tool execution. - Designed to be passed via ``config={"callbacks": [handler]}`` to LangGraph's - ``graph.astream()`` or ``graph.ainvoke()``. - - Span hierarchy produced:: - - (e.g. "message" turn-level span) - โ”œโ”€โ”€ llm: (LLM call) - โ”œโ”€โ”€ tool: (tool execution) - โ””โ”€โ”€ llm: (LLM call) - """ - - def __init__( - self, - trace_id: str, - parent_span_id: str | None = None, - tracing: TracingModule | None = None, - ) -> None: - super().__init__() - self._trace_id = trace_id - self._parent_span_id = parent_span_id - # Lazily initialise TracingModule so the httpx client is created - # inside the *running* event-loop (not at import/construction time). - self._tracing_eager = tracing - self._tracing_lazy: TracingModule | None = None - # Map run_id โ†’ Span for in-flight spans - self._spans: dict[UUID, Span] = {} - - @property - def _tracing(self) -> TracingModule: - if self._tracing_eager is not None: - return self._tracing_eager - if self._tracing_lazy is None: - self._tracing_lazy = TracingModule() - return self._tracing_lazy - - # ------------------------------------------------------------------ - # LLM lifecycle - # ------------------------------------------------------------------ - - @override - async def on_chat_model_start( - self, - serialized: dict[str, Any], - messages: list[list[BaseMessage]], - *, - run_id: UUID, - parent_run_id: UUID | None = None, - tags: list[str] | None = None, - metadata: dict[str, Any] | None = None, - **kwargs: Any, - ) -> None: - model_name = (metadata or {}).get("ls_model_name", "") or _extract_model_name(serialized) - span = await self._tracing.start_span( - trace_id=self._trace_id, - name=f"llm:{model_name}" if model_name else "llm", - input=_serialize_messages(messages), - parent_id=self._parent_span_id, - data={"__span_type__": "COMPLETION"}, - ) - if span: - self._spans[run_id] = span - - @override - async def on_llm_end( - self, - response: LLMResult, - *, - run_id: UUID, - parent_run_id: UUID | None = None, - **kwargs: Any, - ) -> None: - span = self._spans.pop(run_id, None) - if span is None: - return - span.output = _serialize_llm_result(response) - await self._tracing.end_span(trace_id=self._trace_id, span=span) - - @override - async def on_llm_error( - self, - error: BaseException, - *, - run_id: UUID, - parent_run_id: UUID | None = None, - **kwargs: Any, - ) -> None: - span = self._spans.pop(run_id, None) - if span is None: - return - span.output = {"error": str(error)} - await self._tracing.end_span(trace_id=self._trace_id, span=span) - - # ------------------------------------------------------------------ - # Tool lifecycle - # ------------------------------------------------------------------ - - @override - async def on_tool_start( - self, - serialized: dict[str, Any], - input_str: str, - *, - run_id: UUID, - parent_run_id: UUID | None = None, - tags: list[str] | None = None, - metadata: dict[str, Any] | None = None, - inputs: dict[str, Any] | None = None, - **kwargs: Any, - ) -> None: - tool_name = serialized.get("name", "") or serialized.get("id", [""])[-1] - span = await self._tracing.start_span( - trace_id=self._trace_id, - name=f"tool:{tool_name}" if tool_name else "tool", - input={"input": input_str}, - parent_id=self._parent_span_id, - data={"__span_type__": "CUSTOM"}, - ) - if span: - self._spans[run_id] = span - - @override - async def on_tool_end( - self, - output: str, - *, - run_id: UUID, - parent_run_id: UUID | None = None, - **kwargs: Any, - ) -> None: - span = self._spans.pop(run_id, None) - if span is None: - return - span.output = {"output": output} - await self._tracing.end_span(trace_id=self._trace_id, span=span) - - @override - async def on_tool_error( - self, - error: BaseException, - *, - run_id: UUID, - parent_run_id: UUID | None = None, - **kwargs: Any, - ) -> None: - span = self._spans.pop(run_id, None) - if span is None: - return - span.output = {"error": str(error)} - await self._tracing.end_span(trace_id=self._trace_id, span=span) - - -# ------------------------------------------------------------------ -# Helpers -# ------------------------------------------------------------------ - - -def _extract_model_name(serialized: dict[str, Any]) -> str: - """Best-effort model name extraction from the serialized callback dict.""" - kwargs = serialized.get("kwargs", {}) - return kwargs.get("model_name", "") or kwargs.get("model", "") - - -def _serialize_messages(messages: list[list[BaseMessage]]) -> dict[str, Any]: - """Serialize LangChain messages into a JSON-safe dict for the span input.""" - result: list[dict[str, Any]] = [] - for batch in messages: - for msg in batch: - entry: dict[str, Any] = {"type": msg.type, "content": msg.content} - tool_calls = getattr(msg, "tool_calls", None) - if tool_calls: - entry["tool_calls"] = tool_calls - result.append(entry) - return {"messages": result} - - -def _serialize_llm_result(response: LLMResult) -> dict[str, Any]: - """Serialize an LLMResult into a JSON-safe dict for the span output.""" - output: dict[str, Any] = {} - if response.generations: - last_gen = response.generations[-1] - if last_gen: - gen = last_gen[-1] - msg = getattr(gen, "message", None) - - # For reasoning models, content is a list of typed blocks. - # Extract text from the blocks instead of relying on gen.text. - if msg and isinstance(msg.content, list): - text_parts: list[str] = [] - for block in msg.content: - if isinstance(block, dict): - if block.get("type") == "text": - text_parts.append(block.get("text", "")) - output["content"] = "".join(text_parts) if text_parts else gen.text - else: - output["content"] = gen.text - - if msg and hasattr(msg, "tool_calls") and msg.tool_calls: - output["tool_calls"] = [{"name": tc["name"], "args": tc["args"]} for tc in msg.tool_calls] - return output - - -def create_langgraph_tracing_handler( - trace_id: str, - parent_span_id: str | None = None, -) -> AgentexLangGraphTracingHandler: - """Create a LangChain callback handler that records Agentex tracing spans. - - Pass the returned handler to LangGraph via ``config={"callbacks": [handler]}``. - - Args: - trace_id: The trace ID (typically the task/thread ID). - parent_span_id: Optional parent span ID to nest LLM/tool spans under. - - Returns: - An ``AgentexLangGraphTracingHandler`` instance ready to use as a LangChain callback. - """ - return AgentexLangGraphTracingHandler( - trace_id=trace_id, - parent_span_id=parent_span_id, - ) diff --git a/src/agentex/lib/adk/_modules/_pydantic_ai_async.py b/src/agentex/lib/adk/_modules/_pydantic_ai_async.py deleted file mode 100644 index 0bbb5b19d..000000000 --- a/src/agentex/lib/adk/_modules/_pydantic_ai_async.py +++ /dev/null @@ -1,278 +0,0 @@ -"""Async Pydantic AI streaming helper for Agentex. - -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 emitted as full -``adk.messages.create(...)`` calls (Option A โ€” matches the async LangGraph -helper's convention). To stream tool-call argument tokens, see the sync -converter at ``agentex.lib.adk._modules._pydantic_ai_sync`` which yields -``ToolRequestDelta`` events. - -Tracing is opt-in via a ``tracing_handler`` parameter โ€” see -``create_pydantic_ai_tracing_handler`` in -``agentex.lib.adk._modules._pydantic_ai_tracing``. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from agentex.lib.adk._modules._pydantic_ai_tracing import ( - AgentexPydanticAITracingHandler, - ) - - -async def stream_pydantic_ai_events( - stream, - task_id: str, - tracing_handler: "AgentexPydanticAITracingHandler | None" = None, -) -> str: - """Stream Pydantic AI events to Agentex via Redis. - - Args: - stream: Async iterator yielded by ``agent.run_stream_events(...)``. - task_id: The Agentex task ID to stream messages to. - tracing_handler: Optional handler from - ``create_pydantic_ai_tracing_handler(...)``. When provided, each - tool call in the run is also recorded as an Agentex child span - beneath the handler's configured ``parent_span_id``. Streaming - behavior is unchanged when omitted. - - 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. - """ - # Lazy imports so pydantic-ai isn't required at module load time. - import json - - from pydantic_ai.messages import ( - TextPart, - PartEndEvent, - ThinkingPart, - ToolCallPart, - TextPartDelta, - PartDeltaEvent, - PartStartEvent, - ThinkingPartDelta, - FunctionToolResultEvent, - ) - - from agentex.lib import adk - 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 - - text_context = None - reasoning_context = None - final_text = "" - - # Per Pydantic-AI part-index bookkeeping. Part indices restart at 0 on - # each new model response, so we overwrite on PartStartEvent. - part_kind: dict[int, str] = {} - tool_call_info: dict[int, tuple[str, str]] = {} - - async def _close_text(): - nonlocal text_context - if text_context: - await text_context.close() - text_context = None - - async def _close_reasoning(): - nonlocal reasoning_context - if reasoning_context: - await reasoning_context.close() - reasoning_context = None - - try: - async for event in stream: - if isinstance(event, PartStartEvent): - if isinstance(event.part, TextPart): - await _close_reasoning() - await _close_text() - - final_text = "" - text_context = await adk.streaming.streaming_task_message_context( - task_id=task_id, - initial_content=TextContent( - author="agent", - content="", - format="markdown", - ), - ).__aenter__() - part_kind[event.index] = "text" - - # Pydantic AI puts the first streaming chunk in - # PartStartEvent.part.content; surface it as a Delta so it - # actually renders (Start.content is initialization, not body). - if event.part.content: - final_text += event.part.content - await text_context.stream_update( - StreamTaskMessageDelta( - parent_task_message=text_context.task_message, - delta=TextDelta(type="text", text_delta=event.part.content), - type="delta", - ) - ) - - elif isinstance(event.part, ThinkingPart): - await _close_text() - await _close_reasoning() - - reasoning_context = await adk.streaming.streaming_task_message_context( - task_id=task_id, - initial_content=ReasoningContent( - author="agent", - summary=[], - content=[], - type="reasoning", - style="active", - ), - ).__aenter__() - part_kind[event.index] = "reasoning" - - if event.part.content: - await reasoning_context.stream_update( - StreamTaskMessageDelta( - parent_task_message=reasoning_context.task_message, - delta=ReasoningContentDelta( - type="reasoning_content", - content_index=0, - content_delta=event.part.content, - ), - type="delta", - ) - ) - - elif isinstance(event.part, ToolCallPart): - await _close_text() - await _close_reasoning() - tool_call_info[event.index] = ( - event.part.tool_call_id, - event.part.tool_name, - ) - part_kind[event.index] = "tool_call" - - elif isinstance(event, PartDeltaEvent): - kind = part_kind.get(event.index) - if kind == "text" and isinstance(event.delta, TextPartDelta) and text_context: - final_text += event.delta.content_delta - await text_context.stream_update( - StreamTaskMessageDelta( - parent_task_message=text_context.task_message, - delta=TextDelta(type="text", text_delta=event.delta.content_delta), - type="delta", - ) - ) - elif ( - kind == "reasoning" - and isinstance(event.delta, ThinkingPartDelta) - and reasoning_context - and event.delta.content_delta - ): - await reasoning_context.stream_update( - StreamTaskMessageDelta( - parent_task_message=reasoning_context.task_message, - delta=ReasoningContentDelta( - type="reasoning_content", - content_index=0, - content_delta=event.delta.content_delta, - ), - type="delta", - ) - ) - # Tool-call arg deltas: Pydantic AI accumulates them; we - # surface the final args on PartEndEvent below (Option A). - - elif isinstance(event, PartEndEvent): - kind = part_kind.get(event.index) - if kind == "text": - await _close_text() - elif kind == "reasoning": - await _close_reasoning() - elif kind == "tool_call" and isinstance(event.part, ToolCallPart): - tool_call_id, tool_name = tool_call_info.get(event.index, ("", "")) - args = event.part.args - if isinstance(args, str): - try: - args = json.loads(args) if args else {} - except json.JSONDecodeError: - args = {"_raw": args} - elif args is None: - args = {} - await adk.messages.create( - task_id=task_id, - content=ToolRequestContent( - tool_call_id=tool_call_id, - name=tool_name, - arguments=args, - author="agent", - ), - ) - if tracing_handler is not None and tool_call_id: - await tracing_handler.on_tool_start( - tool_call_id=tool_call_id, - tool_name=tool_name, - arguments=args, - ) - - elif isinstance(event, FunctionToolResultEvent): - await _close_text() - await _close_reasoning() - - result = event.part - tool_call_id = result.tool_call_id - tool_name = getattr(result, "tool_name", "") or "" - # Preserve structure for dicts / lists / Pydantic models so the - # UI can render them as JSON, not as Python repr. Matches the - # sync converter's ``_tool_return_content`` helper exactly โ€” - # ``str(content)`` on a dict produces ``"{'k': 'v'}"`` which is - # invalid JSON and unreadable in the UI. - content = getattr(result, "content", None) - content_payload: Any - if content is None: - content_payload = str(result) - elif isinstance(content, (str, int, float, bool, list, dict)): - content_payload = content - elif hasattr(content, "model_dump"): - try: - content_payload = content.model_dump() - except Exception: - content_payload = str(content) - else: - content_payload = str(content) - await adk.messages.create( - task_id=task_id, - content=ToolResponseContent( - tool_call_id=tool_call_id, - name=tool_name, - content=content_payload, - author="agent", - ), - ) - if tracing_handler is not None and tool_call_id: - await tracing_handler.on_tool_end( - tool_call_id=tool_call_id, - result=content_payload, - ) - - # FunctionToolCallEvent / FinalResultEvent / AgentRunResultEvent - # are intentionally ignored โ€” same as the sync converter. - - finally: - if text_context: - await text_context.close() - if reasoning_context: - await reasoning_context.close() - - return final_text diff --git a/src/agentex/lib/adk/_modules/_pydantic_ai_sync.py b/src/agentex/lib/adk/_modules/_pydantic_ai_sync.py deleted file mode 100644 index d94c0ae12..000000000 --- a/src/agentex/lib/adk/_modules/_pydantic_ai_sync.py +++ /dev/null @@ -1,334 +0,0 @@ -"""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 -""" - -from __future__ import annotations - -import json -from typing import TYPE_CHECKING, Any, AsyncIterator - -from pydantic_ai.run import AgentRunResultEvent - -if TYPE_CHECKING: - from agentex.lib.adk._modules._pydantic_ai_tracing import ( - AgentexPydanticAITracingHandler, - ) -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], - tracing_handler: "AgentexPydanticAITracingHandler | 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``). - tracing_handler: Optional handler from - ``create_pydantic_ai_tracing_handler(...)``. When provided, each - tool call in the run is also recorded as an Agentex child span - beneath the handler's configured ``parent_span_id``. Streaming - behavior is unchanged when omitted. - - 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) - # Tool-call parts end with the model's full args known. Open a - # tracing child span for the tool execution now; close it when - # FunctionToolResultEvent arrives below. - if tracing_handler is not None and isinstance(event.part, ToolCallPart) and event.part.tool_call_id: - args: dict[str, Any] | str | None - raw_args = event.part.args - if isinstance(raw_args, dict): - args = dict(raw_args) - elif isinstance(raw_args, str): - try: - args = json.loads(raw_args) if raw_args else {} - except json.JSONDecodeError: - args = {"_raw": raw_args} - else: - args = {} - await tracing_handler.on_tool_start( - tool_call_id=event.part.tool_call_id, - tool_name=event.part.tool_name, - arguments=args, - ) - - 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, - ), - ) - if tracing_handler is not None and tool_call_id: - await tracing_handler.on_tool_end( - tool_call_id=tool_call_id, - result=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). - continue - - else: - logger.debug("Unhandled Pydantic AI event type: %r", type(event).__name__) diff --git a/src/agentex/lib/adk/_modules/_pydantic_ai_tracing.py b/src/agentex/lib/adk/_modules/_pydantic_ai_tracing.py deleted file mode 100644 index aa9d906eb..000000000 --- a/src/agentex/lib/adk/_modules/_pydantic_ai_tracing.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Tracing handler that records Agentex spans for tool calls in a pydantic-ai agent run. - -Mirrors the LangGraph tracing handler pattern: the caller creates a handler -bound to a ``trace_id`` and a ``parent_span_id``, then hands it to -``stream_pydantic_ai_events(..., tracing_handler=handler)``. The streamer -calls ``on_tool_start`` / ``on_tool_end`` as it observes the corresponding -events in the agent stream, and the handler records one Agentex child span -per tool call. - -Why a handler-on-the-streamer rather than an OpenTelemetry bridge: -pydantic-ai exposes its stream of ``AgentStreamEvent`` directly, and that -stream already contains every signal we need to record tool spans. Going -through an OTel processor would require setting up an OTel ``TracerProvider`` -plus a bridge processor โ€” that's a much larger investment, and orthogonal -to the streaming path we already own. This handler hooks into the same -event stream the UI-streaming helper consumes, so a single pass over the -events produces both: live deltas on Redis and child spans on the AgentEx -tracing pipeline. - -Why span IDs are derived from ``tool_call_id`` instead of held in a dict: -pydantic-ai's ``TemporalAgent`` splits the agent run across one or more -Temporal activities. The ``event_stream_handler`` is invoked once per -activity, with a fresh handler instance each time. So ``on_tool_start`` -(emitted inside the model activity that issued the tool call) and -``on_tool_end`` (emitted inside the next model activity, after the tool -runs) land in different handler instances โ€” an in-memory dict can't pair -them. Deriving the span ID deterministically from ``(trace_id, -tool_call_id)`` makes the open/close pairing stateless: ``on_tool_end`` -re-derives the same ID and PATCHes the existing span directly. - -Span hierarchy produced:: - - (e.g. "Turn N", created by the caller) - โ”œโ”€โ”€ tool: (one child span per tool call) - โ””โ”€โ”€ tool: -""" - -from __future__ import annotations - -import uuid -from typing import Any -from datetime import UTC, datetime - -from agentex import AsyncAgentex -from agentex.lib.utils.logging import make_logger -from agentex.lib.adk._modules.tracing import TracingModule -from agentex.lib.adk.utils._modules.client import create_async_agentex_client - -logger = make_logger(__name__) - - -# Stable namespace for deriving tool-call span IDs. The exact UUID value is -# arbitrary; it just needs to be a constant so the same (trace_id, tool_call_id) -# always maps to the same span ID across handler invocations. -_TOOL_SPAN_NAMESPACE = uuid.UUID("8c2f9a2b-3e4d-4b5a-9c1f-0a1b2c3d4e5f") - - -def _tool_span_id(trace_id: str, tool_call_id: str) -> str: - """Deterministic span ID for a given tool call within a trace.""" - return str(uuid.uuid5(_TOOL_SPAN_NAMESPACE, f"{trace_id}:{tool_call_id}")) - - -class AgentexPydanticAITracingHandler: - """Records Agentex tracing spans for tool calls observed in a pydantic-ai event stream. - - Pass an instance to ``stream_pydantic_ai_events(..., tracing_handler=...)`` - or call ``on_tool_start`` / ``on_tool_end`` yourself if you're consuming - the event stream by hand. - """ - - def __init__( - self, - trace_id: str, - parent_span_id: str | None = None, - task_id: str | None = None, - tracing: TracingModule | None = None, - client: AsyncAgentex | None = None, - ) -> None: - self._trace_id = trace_id - self._parent_span_id = parent_span_id - # task_id on the span record (separate from trace_id) is what the - # AgentEx UI's per-task spans dropdown filters by. If you want your - # tool spans visible in that dropdown, set this to the task ID. - self._task_id = task_id - # ``_tracing`` is retained for callers / tests that want to inject a - # mocked TracingModule, even though the on_tool_* methods now go - # direct to the AgentEx client (see module docstring for why). - self._tracing_eager = tracing - self._tracing_lazy: TracingModule | None = None - # Defer client construction until first use so httpx binds to the - # running event loop (matches the TracingModule pattern). - self._client_eager = client - self._client_lazy: AsyncAgentex | None = None - - @property - def _tracing(self) -> TracingModule: - if self._tracing_eager is not None: - return self._tracing_eager - if self._tracing_lazy is None: - self._tracing_lazy = TracingModule() - return self._tracing_lazy - - @property - def _client(self) -> AsyncAgentex: - if self._client_eager is not None: - return self._client_eager - if self._client_lazy is None: - self._client_lazy = create_async_agentex_client() - return self._client_lazy - - async def on_tool_start( - self, - tool_call_id: str, - tool_name: str, - arguments: dict[str, Any] | str | None, - ) -> None: - """Open a child span for a tool call. - - Uses a deterministic span ID derived from ``tool_call_id`` so that - ``on_tool_end`` โ€” which may run inside a different handler instance - when pydantic-ai splits the run across Temporal activities โ€” can - close the same span without needing in-memory state. - """ - span_id = _tool_span_id(self._trace_id, tool_call_id) - await self._client.spans.create( - id=span_id, - trace_id=self._trace_id, - task_id=self._task_id, - parent_id=self._parent_span_id, - name=f"tool:{tool_name}" if tool_name else "tool", - start_time=datetime.now(UTC), - input={"arguments": arguments}, - data={"__span_type__": "CUSTOM"}, - ) - - async def on_tool_end(self, tool_call_id: str, result: Any) -> None: - """Close a child span by PATCHing its end_time and output. - - Re-derives the deterministic span ID from ``tool_call_id`` and updates - the existing span record directly. No in-memory span lookup, so this - works even when ``on_tool_start`` ran inside a different handler - instance (e.g. across pydantic-ai TemporalAgent activity boundaries). - """ - span_id = _tool_span_id(self._trace_id, tool_call_id) - await self._client.spans.update( - span_id, - end_time=datetime.now(UTC), - output={"result": result}, - ) - - async def on_tool_error(self, tool_call_id: str, error: BaseException | str) -> None: - """Close a child span with an error payload as output.""" - span_id = _tool_span_id(self._trace_id, tool_call_id) - await self._client.spans.update( - span_id, - end_time=datetime.now(UTC), - output={"error": str(error)}, - ) - - -def create_pydantic_ai_tracing_handler( - trace_id: str, - parent_span_id: str | None = None, - task_id: str | None = None, -) -> AgentexPydanticAITracingHandler: - """Create a tracing handler that records Agentex spans for pydantic-ai tool calls. - - Args: - trace_id: The trace ID. Typically the Agentex task ID. - parent_span_id: Optional parent span ID to nest tool spans under. If - omitted, the tool spans become trace-root spans. - task_id: Optional task ID stamped onto each span. Required for the - AgentEx UI's per-task spans dropdown to display the spans. - - Returns: - A handler suitable for passing to ``stream_pydantic_ai_events(..., tracing_handler=...)``. - """ - return AgentexPydanticAITracingHandler( - trace_id=trace_id, - parent_span_id=parent_span_id, - task_id=task_id, - ) diff --git a/src/agentex/lib/adk/_modules/acp.py b/src/agentex/lib/adk/_modules/acp.py deleted file mode 100644 index 0c8cff05a..000000000 --- a/src/agentex/lib/adk/_modules/acp.py +++ /dev/null @@ -1,290 +0,0 @@ -# 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: The name of the task. - 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 deleted file mode 100644 index 733372ec7..000000000 --- a/src/agentex/lib/adk/_modules/agent_task_tracker.py +++ /dev/null @@ -1,180 +0,0 @@ -# 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 deleted file mode 100644 index eee8b9f7e..000000000 --- a/src/agentex/lib/adk/_modules/agents.py +++ /dev/null @@ -1,80 +0,0 @@ -# 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 deleted file mode 100644 index 544042941..000000000 --- a/src/agentex/lib/adk/_modules/checkpointer.py +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 4995ae172..000000000 --- a/src/agentex/lib/adk/_modules/events.py +++ /dev/null @@ -1,145 +0,0 @@ -# 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 deleted file mode 100644 index 992683b58..000000000 --- a/src/agentex/lib/adk/_modules/messages.py +++ /dev/null @@ -1,301 +0,0 @@ -# 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 deleted file mode 100644 index a5a343e92..000000000 --- a/src/agentex/lib/adk/_modules/state.py +++ /dev/null @@ -1,295 +0,0 @@ -# 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 deleted file mode 100644 index 561b1165d..000000000 --- a/src/agentex/lib/adk/_modules/streaming.py +++ /dev/null @@ -1,89 +0,0 @@ -# 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 deleted file mode 100644 index 7b304a656..000000000 --- a/src/agentex/lib/adk/_modules/tasks.py +++ /dev/null @@ -1,431 +0,0 @@ -# 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 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 deleted file mode 100644 index 8694c2078..000000000 --- a/src/agentex/lib/adk/_modules/tracing.py +++ /dev/null @@ -1,239 +0,0 @@ -# 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.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.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.tracer import AsyncTracer -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) - - -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 - 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, - ) - - 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(): - 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, - ) - 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(): - 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, - ) - 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 deleted file mode 100644 index 9167396f4..000000000 --- a/src/agentex/lib/adk/providers/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/adk/providers/_modules/litellm.py b/src/agentex/lib/adk/providers/_modules/litellm.py deleted file mode 100644 index 9793d850f..000000000 --- a/src/agentex/lib/adk/providers/_modules/litellm.py +++ /dev/null @@ -1,240 +0,0 @@ -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 deleted file mode 100644 index 418c487e4..000000000 --- a/src/agentex/lib/adk/providers/_modules/openai.py +++ /dev/null @@ -1,514 +0,0 @@ -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/sgp.py b/src/agentex/lib/adk/providers/_modules/sgp.py deleted file mode 100644 index fab765b76..000000000 --- a/src/agentex/lib/adk/providers/_modules/sgp.py +++ /dev/null @@ -1,87 +0,0 @@ -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 deleted file mode 100644 index a34cfcda1..000000000 --- a/src/agentex/lib/adk/providers/_modules/sync_provider.py +++ /dev/null @@ -1,690 +0,0 @@ -"""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 openai.types.responses import ( - ResponseTextDeltaEvent, - ResponseFunctionToolCall, - ResponseFunctionWebSearch, - ResponseOutputItemDoneEvent, - ResponseOutputItemAddedEvent, - ResponseCodeInterpreterToolCall, - ResponseReasoningSummaryPartAddedEvent, - ResponseReasoningSummaryTextDeltaEvent, -) -from agents.models.openai_provider import OpenAIProvider -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 import AsyncAgentex -from agentex.lib.utils.logging import make_logger -from agentex.lib.core.tracing.tracer import AsyncTracer -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__) - - -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.""" - - 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, - } - - 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, - } - 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.""" - - 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 - - -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 - # Handle the arguments field which might be a string or None - if tool_call_item.arguments: - if isinstance(tool_call_item.arguments, str): - import json - tool_arguments = json.loads(tool_call_item.arguments) if tool_call_item.arguments else {} - else: - tool_arguments = tool_call_item.arguments - else: - tool_arguments = {} - else: - # Generic handling for any tool call type - tool_name = getattr(tool_call_item, "name", type(tool_call_item).__name__) - # Handle the arguments field which might be a string or None - if hasattr(tool_call_item, "arguments"): - arguments = tool_call_item.arguments - if isinstance(arguments, str): - import json - tool_arguments = json.loads(arguments) if arguments else {} - elif arguments is None: - tool_arguments = {} - else: - tool_arguments = 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): - """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 - seen_tool_output = False # Track if we've seen tool output to know when final text starts - 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: - # Get the message type to decide whether to send done event - message_type = item_id_to_type.get(item_id, "text") - - # Don't send done events for reasoning content/summary - # They just end with their last delta - if message_type not in ("reasoning_content", "reasoning_summary"): - 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 - yield StreamTaskMessageStart( - type="start", - index=item_id_to_index[item_id], - content=TextContent( - type="text", - author="agent", - content="", # Start with empty content - ), - ) - - # 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 - yield StreamTaskMessageStart( - type="start", - index=item_id_to_index[item_id], - content=TextContent( - type="text", - author="agent", - content="", # Start with empty content - ), - ) - - # 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 - if item_id and item_id not in item_id_to_index: - # Check if this is truly a NEW text message after tools - # We need to differentiate between the first text and the final text after tools - if seen_tool_output: - # This is the final text message after tool execution - message_index += 1 - item_id_to_index[item_id] = message_index - else: - 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 - seen_tool_output = True # Mark that we've seen tool output so next text gets new index - yield StreamTaskMessageFull( - type="full", - index=message_index, - content=tool_response_content, - ) - diff --git a/src/agentex/lib/adk/utils/__init__.py b/src/agentex/lib/adk/utils/__init__.py deleted file mode 100644 index c190cb6e7..000000000 --- a/src/agentex/lib/adk/utils/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/adk/utils/_modules/client.py b/src/agentex/lib/adk/utils/_modules/client.py deleted file mode 100644 index 725289631..000000000 --- a/src/agentex/lib/adk/utils/_modules/client.py +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 29e6b6b2b..000000000 --- a/src/agentex/lib/adk/utils/_modules/templating.py +++ /dev/null @@ -1,96 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/cli/commands/__init__.py b/src/agentex/lib/cli/commands/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/cli/commands/agents.py b/src/agentex/lib/cli/commands/agents.py deleted file mode 100644 index 05e613a99..000000000 --- a/src/agentex/lib/cli/commands/agents.py +++ /dev/null @@ -1,447 +0,0 @@ -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 AgentManifest -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)", - ), -): - """ - 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 - ) - 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 = AgentManifest.from_yaml(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 = AgentManifest.from_yaml(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 deleted file mode 100644 index 307a5d0e8..000000000 --- a/src/agentex/lib/cli/commands/init.py +++ /dev/null @@ -1,427 +0,0 @@ -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" - DEFAULT = "default" - DEFAULT_LANGGRAPH = "default-langgraph" - DEFAULT_PYDANTIC_AI = "default-pydantic-ai" - 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" - - -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.DEFAULT: ["acp.py"], - TemplateType.DEFAULT_LANGGRAPH: ["acp.py", "graph.py", "tools.py"], - TemplateType.DEFAULT_PYDANTIC_AI: ["acp.py", "agent.py", "tools.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"], - }[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 + LangGraph", "value": TemplateType.DEFAULT_LANGGRAPH}, - {"name": "Async ACP + Pydantic AI", "value": TemplateType.DEFAULT_PYDANTIC_AI}, - ], - ).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}, - ], - ).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}, - ], - ).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 deleted file mode 100644 index fa3c098d2..000000000 --- a/src/agentex/lib/cli/commands/main.py +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index ee5e5477e..000000000 --- a/src/agentex/lib/cli/commands/secrets.py +++ /dev/null @@ -1,171 +0,0 @@ -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 AgentManifest -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 = AgentManifest.from_yaml(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 deleted file mode 100644 index 43d54894b..000000000 --- a/src/agentex/lib/cli/commands/tasks.py +++ /dev/null @@ -1,119 +0,0 @@ -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 deleted file mode 100644 index e192b0e53..000000000 --- a/src/agentex/lib/cli/commands/uv.py +++ /dev/null @@ -1,135 +0,0 @@ -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 deleted file mode 100644 index 764b3565f..000000000 --- a/src/agentex/lib/cli/debug/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -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 deleted file mode 100644 index 3b30e68e2..000000000 --- a/src/agentex/lib/cli/debug/debug_config.py +++ /dev/null @@ -1,115 +0,0 @@ -""" -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 deleted file mode 100644 index 98746387f..000000000 --- a/src/agentex/lib/cli/debug/debug_handlers.py +++ /dev/null @@ -1,176 +0,0 @@ -""" -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 .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, - ) - - -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, - ) - - -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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/cli/handlers/agent_handlers.py b/src/agentex/lib/cli/handlers/agent_handlers.py deleted file mode 100644 index ffed30aa4..000000000 --- a/src/agentex/lib/cli/handlers/agent_handlers.py +++ /dev/null @@ -1,278 +0,0 @@ -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 AgentManifest, BuildContextManager - -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, -) -> 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' - - Returns: - The image URL - """ - agent_manifest = AgentManifest.from_yaml(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 agent_manifest.context_manager(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, - } - - # 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 = AgentManifest.from_yaml(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 agent_manifest.context_manager(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 deleted file mode 100644 index 1d67b55e3..000000000 --- a/src/agentex/lib/cli/handlers/cleanup_handlers.py +++ /dev/null @@ -1,183 +0,0 @@ -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 deleted file mode 100644 index 35cd21347..000000000 --- a/src/agentex/lib/cli/handlers/deploy_handlers.py +++ /dev/null @@ -1,586 +0,0 @@ -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.lib.cli.utils.exceptions import HelmError, DeploymentError -from agentex.lib.cli.utils.path_utils import PathResolutionError, calculate_docker_acp_module -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_config import AgentConfig -from agentex.lib.sdk.config.agent_manifest import AgentManifest -from agentex.lib.sdk.config.environment_config import OciRegistryConfig, AgentEnvironmentConfig - -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 = AgentManifest.from_yaml(file_path=manifest_path) - - # Load agent environment configuration - agent_env_config = None - if environment_name: - manifest_dir = Path(manifest_path).parent - environments_config = manifest.load_environments_config(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 deleted file mode 100644 index adf44a197..000000000 --- a/src/agentex/lib/cli/handlers/run_handlers.py +++ /dev/null @@ -1,412 +0,0 @@ -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.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 AgentManifest -from agentex.lib.cli.handlers.cleanup_handlers import cleanup_agent_workflows, should_cleanup_on_restart - -logger = make_logger(__name__) -console = Console() - - -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, - ) - - -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, - ) - - -async def stream_process_output(process: asyncio.subprocess.Process, prefix: str): - """Stream process output with prefix""" - try: - if process.stdout is None: - return - while True: - line = await process.stdout.readline() - if not line: - break - 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: - logger.debug(f"Output streaming ended for {prefix}: {e}") - - -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 = AgentManifest.from_yaml(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 deleted file mode 100644 index c424de0ec..000000000 --- a/src/agentex/lib/cli/handlers/secret_handlers.py +++ /dev/null @@ -1,672 +0,0 @@ -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.lib.types.credentials import CredentialMapping -from agentex.lib.cli.utils.cli_utils import handle_questionary_cancellation -from agentex.lib.cli.utils.kubectl_utils import get_k8s_client -from agentex.lib.sdk.config.agent_config import AgentConfig -from agentex.lib.sdk.config.agent_manifest import AgentManifest -from agentex.lib.sdk.config.deployment_config import ( - DeploymentConfig, - ImagePullSecretConfig, - InjectedSecretsValues, -) -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-langgraph/.dockerignore.j2 b/src/agentex/lib/cli/templates/default-langgraph/.dockerignore.j2 deleted file mode 100644 index c2d7fca4d..000000000 --- a/src/agentex/lib/cli/templates/default-langgraph/.dockerignore.j2 +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 015f49ef7..000000000 --- a/src/agentex/lib/cli/templates/default-langgraph/.env.example.j2 +++ /dev/null @@ -1,13 +0,0 @@ -# {{ 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 deleted file mode 100644 index 582434ac9..000000000 --- a/src/agentex/lib/cli/templates/default-langgraph/Dockerfile-uv.j2 +++ /dev/null @@ -1,47 +0,0 @@ -# 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 {{ project_path_from_build_root }}/uv.lock ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --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 --locked --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 deleted file mode 100644 index 0395caf74..000000000 --- a/src/agentex/lib/cli/templates/default-langgraph/Dockerfile.j2 +++ /dev/null @@ -1,42 +0,0 @@ -# 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 deleted file mode 100644 index 59bea5bdb..000000000 --- a/src/agentex/lib/cli/templates/default-langgraph/README.md.j2 +++ /dev/null @@ -1,85 +0,0 @@ -# {{ 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 deleted file mode 100644 index d3a68303f..000000000 --- a/src/agentex/lib/cli/templates/default-langgraph/dev.ipynb.j2 +++ /dev/null @@ -1,126 +0,0 @@ -{ - "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 deleted file mode 100644 index f802776f0..000000000 --- a/src/agentex/lib/cli/templates/default-langgraph/environments.yaml.j2 +++ /dev/null @@ -1,57 +0,0 @@ -# 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 deleted file mode 100644 index 2d94ba41c..000000000 --- a/src/agentex/lib/cli/templates/default-langgraph/manifest.yaml.j2 +++ /dev/null @@ -1,120 +0,0 @@ -# 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 }} - - # 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 deleted file mode 100644 index 3309dc07e..000000000 --- a/src/agentex/lib/cli/templates/default-langgraph/project/acp.py.j2 +++ /dev/null @@ -1,97 +0,0 @@ -""" -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.adk import create_langgraph_tracing_handler, stream_langgraph_events -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 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 - user_message = params.event.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: - callback = create_langgraph_tracing_handler( - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - ) - - stream = graph.astream( - {"messages": [{"role": "user", "content": user_message}]}, - config={ - "configurable": {"thread_id": task_id}, - "callbacks": [callback], - }, - stream_mode=["messages", "updates"], - ) - - final_output = await stream_langgraph_events(stream, task_id) - - if turn_span: - turn_span.output = {"final_output": final_output} - - -@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 deleted file mode 100644 index b7fd2d6bd..000000000 --- a/src/agentex/lib/cli/templates/default-langgraph/project/graph.py.j2 +++ /dev/null @@ -1,63 +0,0 @@ -""" -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 deleted file mode 100644 index 1b402a906..000000000 --- a/src/agentex/lib/cli/templates/default-langgraph/project/tools.py.j2 +++ /dev/null @@ -1,32 +0,0 @@ -""" -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 deleted file mode 100644 index 3c752f025..000000000 --- a/src/agentex/lib/cli/templates/default-langgraph/pyproject.toml.j2 +++ /dev/null @@ -1,35 +0,0 @@ -[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 deleted file mode 100644 index 4a148e901..000000000 --- a/src/agentex/lib/cli/templates/default-langgraph/requirements.txt.j2 +++ /dev/null @@ -1,10 +0,0 @@ -# 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 deleted file mode 100644 index ee71f177c..000000000 --- a/src/agentex/lib/cli/templates/default-langgraph/test_agent.py.j2 +++ /dev/null @@ -1,147 +0,0 @@ -""" -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-pydantic-ai/.dockerignore.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/.dockerignore.j2 deleted file mode 100644 index c2d7fca4d..000000000 --- a/src/agentex/lib/cli/templates/default-pydantic-ai/.dockerignore.j2 +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 1e81b15dd..000000000 --- a/src/agentex/lib/cli/templates/default-pydantic-ai/.env.example.j2 +++ /dev/null @@ -1,12 +0,0 @@ -# {{ 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 deleted file mode 100644 index 582434ac9..000000000 --- a/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile-uv.j2 +++ /dev/null @@ -1,47 +0,0 @@ -# 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 {{ project_path_from_build_root }}/uv.lock ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --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 --locked --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 deleted file mode 100644 index 0395caf74..000000000 --- a/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile.j2 +++ /dev/null @@ -1,42 +0,0 @@ -# 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 deleted file mode 100644 index 40ca35458..000000000 --- a/src/agentex/lib/cli/templates/default-pydantic-ai/README.md.j2 +++ /dev/null @@ -1,77 +0,0 @@ -# {{ 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 deleted file mode 100644 index d3a68303f..000000000 --- a/src/agentex/lib/cli/templates/default-pydantic-ai/dev.ipynb.j2 +++ /dev/null @@ -1,126 +0,0 @@ -{ - "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 deleted file mode 100644 index f802776f0..000000000 --- a/src/agentex/lib/cli/templates/default-pydantic-ai/environments.yaml.j2 +++ /dev/null @@ -1,57 +0,0 @@ -# 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 deleted file mode 100644 index 2d94ba41c..000000000 --- a/src/agentex/lib/cli/templates/default-pydantic-ai/manifest.yaml.j2 +++ /dev/null @@ -1,120 +0,0 @@ -# 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 }} - - # 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 deleted file mode 100644 index 5692396b2..000000000 --- a/src/agentex/lib/cli/templates/default-pydantic-ai/project/acp.py.j2 +++ /dev/null @@ -1,166 +0,0 @@ -"""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 create_agent -from pydantic_ai.run import AgentRunResultEvent -from pydantic_ai.messages import ModelMessagesTypeAdapter - -import agentex.lib.adk as adk -from agentex.lib.adk import ( - stream_pydantic_ai_events, - create_pydantic_ai_tracing_handler, -) -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.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__) - -# 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 - 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) - - # 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: - tracing_handler = create_pydantic_ai_tracing_handler( - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - task_id=task_id, - ) - - # Wrap the pydantic-ai event stream so we can capture the final - # AgentRunResultEvent (which carries the full message list for the - # next turn) without changing the streaming-helper's signature. - 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: - final_output = await stream_pydantic_ai_events( - tee_messages(stream), task_id, tracing_handler=tracing_handler - ) - - # 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": final_output} - - -@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 deleted file mode 100644 index 3e6fd1711..000000000 --- a/src/agentex/lib/cli/templates/default-pydantic-ai/project/agent.py.j2 +++ /dev/null @@ -1,43 +0,0 @@ -"""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 deleted file mode 100644 index bab87942a..000000000 --- a/src/agentex/lib/cli/templates/default-pydantic-ai/project/tools.py.j2 +++ /dev/null @@ -1,20 +0,0 @@ -"""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 deleted file mode 100644 index 8881c5b74..000000000 --- a/src/agentex/lib/cli/templates/default-pydantic-ai/pyproject.toml.j2 +++ /dev/null @@ -1,34 +0,0 @@ -[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 deleted file mode 100644 index 75e880b53..000000000 --- a/src/agentex/lib/cli/templates/default-pydantic-ai/requirements.txt.j2 +++ /dev/null @@ -1,9 +0,0 @@ -# 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 deleted file mode 100644 index ee71f177c..000000000 --- a/src/agentex/lib/cli/templates/default-pydantic-ai/test_agent.py.j2 +++ /dev/null @@ -1,147 +0,0 @@ -""" -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 deleted file mode 100644 index c2d7fca4d..000000000 --- a/src/agentex/lib/cli/templates/default/.dockerignore.j2 +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 015f49ef7..000000000 --- a/src/agentex/lib/cli/templates/default/.env.example.j2 +++ /dev/null @@ -1,13 +0,0 @@ -# {{ 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 deleted file mode 100644 index 582434ac9..000000000 --- a/src/agentex/lib/cli/templates/default/Dockerfile-uv.j2 +++ /dev/null @@ -1,47 +0,0 @@ -# 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 {{ project_path_from_build_root }}/uv.lock ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --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 --locked --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 deleted file mode 100644 index 0395caf74..000000000 --- a/src/agentex/lib/cli/templates/default/Dockerfile.j2 +++ /dev/null @@ -1,42 +0,0 @@ -# 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 deleted file mode 100644 index 26d49c8f7..000000000 --- a/src/agentex/lib/cli/templates/default/README.md.j2 +++ /dev/null @@ -1,214 +0,0 @@ -# {{ 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 deleted file mode 100644 index d3a68303f..000000000 --- a/src/agentex/lib/cli/templates/default/dev.ipynb.j2 +++ /dev/null @@ -1,126 +0,0 @@ -{ - "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 deleted file mode 100644 index f802776f0..000000000 --- a/src/agentex/lib/cli/templates/default/environments.yaml.j2 +++ /dev/null @@ -1,57 +0,0 @@ -# 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 deleted file mode 100644 index 61c9064ed..000000000 --- a/src/agentex/lib/cli/templates/default/manifest.yaml.j2 +++ /dev/null @@ -1,119 +0,0 @@ -# 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 }} - - # 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 deleted file mode 100644 index b0da14a5c..000000000 --- a/src/agentex/lib/cli/templates/default/project/acp.py.j2 +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index 34e04e6a4..000000000 --- a/src/agentex/lib/cli/templates/default/pyproject.toml.j2 +++ /dev/null @@ -1,32 +0,0 @@ -[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 deleted file mode 100644 index 0b8ae19b3..000000000 --- a/src/agentex/lib/cli/templates/default/requirements.txt.j2 +++ /dev/null @@ -1,5 +0,0 @@ -# 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 deleted file mode 100644 index ee71f177c..000000000 --- a/src/agentex/lib/cli/templates/default/test_agent.py.j2 +++ /dev/null @@ -1,147 +0,0 @@ -""" -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-langgraph/.dockerignore.j2 b/src/agentex/lib/cli/templates/sync-langgraph/.dockerignore.j2 deleted file mode 100644 index c2d7fca4d..000000000 --- a/src/agentex/lib/cli/templates/sync-langgraph/.dockerignore.j2 +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 015f49ef7..000000000 --- a/src/agentex/lib/cli/templates/sync-langgraph/.env.example.j2 +++ /dev/null @@ -1,13 +0,0 @@ -# {{ 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 deleted file mode 100644 index 582434ac9..000000000 --- a/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile-uv.j2 +++ /dev/null @@ -1,47 +0,0 @@ -# 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 {{ project_path_from_build_root }}/uv.lock ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --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 --locked --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 deleted file mode 100644 index 4d9f41d45..000000000 --- a/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile.j2 +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index d0620a302..000000000 --- a/src/agentex/lib/cli/templates/sync-langgraph/README.md.j2 +++ /dev/null @@ -1,83 +0,0 @@ -# {{ 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 deleted file mode 100644 index d8c10a65a..000000000 --- a/src/agentex/lib/cli/templates/sync-langgraph/dev.ipynb.j2 +++ /dev/null @@ -1,167 +0,0 @@ -{ - "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 deleted file mode 100644 index 73924abdd..000000000 --- a/src/agentex/lib/cli/templates/sync-langgraph/environments.yaml.j2 +++ /dev/null @@ -1,53 +0,0 @@ -# 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 deleted file mode 100644 index 7bf2cb355..000000000 --- a/src/agentex/lib/cli/templates/sync-langgraph/manifest.yaml.j2 +++ /dev/null @@ -1,117 +0,0 @@ -# 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 }} - - # 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 deleted file mode 100644 index 54538d0c9..000000000 --- a/src/agentex/lib/cli/templates/sync-langgraph/project/acp.py.j2 +++ /dev/null @@ -1,98 +0,0 @@ -""" -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.adk import create_langgraph_tracing_handler, convert_langgraph_to_agentex_events -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.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 - user_message = params.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: - callback = create_langgraph_tracing_handler( - trace_id=thread_id, - parent_span_id=turn_span.id if turn_span else None, - ) - - stream = graph.astream( - {"messages": [{"role": "user", "content": user_message}]}, - config={ - "configurable": {"thread_id": thread_id}, - "callbacks": [callback], - }, - stream_mode=["messages", "updates"], - ) - - final_text = "" - async for event in convert_langgraph_to_agentex_events(stream): - # 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 deleted file mode 100644 index 8b1f6297f..000000000 --- a/src/agentex/lib/cli/templates/sync-langgraph/project/graph.py.j2 +++ /dev/null @@ -1,68 +0,0 @@ -""" -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 deleted file mode 100644 index 1b402a906..000000000 --- a/src/agentex/lib/cli/templates/sync-langgraph/project/tools.py.j2 +++ /dev/null @@ -1,32 +0,0 @@ -""" -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 deleted file mode 100644 index 3c752f025..000000000 --- a/src/agentex/lib/cli/templates/sync-langgraph/pyproject.toml.j2 +++ /dev/null @@ -1,35 +0,0 @@ -[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 deleted file mode 100644 index 4a148e901..000000000 --- a/src/agentex/lib/cli/templates/sync-langgraph/requirements.txt.j2 +++ /dev/null @@ -1,10 +0,0 @@ -# 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 deleted file mode 100644 index 7de4684f4..000000000 --- a/src/agentex/lib/cli/templates/sync-langgraph/test_agent.py.j2 +++ /dev/null @@ -1,70 +0,0 @@ -""" -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 deleted file mode 100644 index c2d7fca4d..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/.dockerignore.j2 +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 015f49ef7..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/.env.example.j2 +++ /dev/null @@ -1,13 +0,0 @@ -# {{ 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 deleted file mode 100644 index 582434ac9..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile-uv.j2 +++ /dev/null @@ -1,47 +0,0 @@ -# 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 {{ project_path_from_build_root }}/uv.lock ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --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 --locked --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 deleted file mode 100644 index 4d9f41d45..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile.j2 +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 9416f2477..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/README.md.j2 +++ /dev/null @@ -1,324 +0,0 @@ -# {{ 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 - user_message = params.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 deleted file mode 100644 index d8c10a65a..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/dev.ipynb.j2 +++ /dev/null @@ -1,167 +0,0 @@ -{ - "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 deleted file mode 100644 index 73924abdd..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/environments.yaml.j2 +++ /dev/null @@ -1,53 +0,0 @@ -# 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 deleted file mode 100644 index bc2910f2a..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/manifest.yaml.j2 +++ /dev/null @@ -1,118 +0,0 @@ -# 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 }} - - # 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 deleted file mode 100644 index e394e14c2..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/acp.py.j2 +++ /dev/null @@ -1,80 +0,0 @@ -"""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 - 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: - 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 deleted file mode 100644 index 07546bffb..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/agent.py.j2 +++ /dev/null @@ -1,91 +0,0 @@ -"""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 deleted file mode 100644 index 8c4a173d0..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/tools.py.j2 +++ /dev/null @@ -1,29 +0,0 @@ -"""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 deleted file mode 100644 index 79e35cf0b..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/pyproject.toml.j2 +++ /dev/null @@ -1,36 +0,0 @@ -[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 deleted file mode 100644 index 6f73c3ae3..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/requirements.txt.j2 +++ /dev/null @@ -1,11 +0,0 @@ -# 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 deleted file mode 100644 index 8fa89bff8..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/test_agent.py.j2 +++ /dev/null @@ -1,135 +0,0 @@ -"""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 deleted file mode 100644 index c2d7fca4d..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents/.dockerignore.j2 +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 015f49ef7..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents/.env.example.j2 +++ /dev/null @@ -1,13 +0,0 @@ -# {{ 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 deleted file mode 100644 index 582434ac9..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile-uv.j2 +++ /dev/null @@ -1,47 +0,0 @@ -# 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 {{ project_path_from_build_root }}/uv.lock ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --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 --locked --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 deleted file mode 100644 index 4d9f41d45..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile.j2 +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index a8ad10799..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents/README.md.j2 +++ /dev/null @@ -1,313 +0,0 @@ -# {{ 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 - user_message = params.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 deleted file mode 100644 index d8c10a65a..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents/dev.ipynb.j2 +++ /dev/null @@ -1,167 +0,0 @@ -{ - "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 deleted file mode 100644 index 73924abdd..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents/environments.yaml.j2 +++ /dev/null @@ -1,53 +0,0 @@ -# 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 deleted file mode 100644 index 965769233..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents/manifest.yaml.j2 +++ /dev/null @@ -1,115 +0,0 @@ -# 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 }} - - # 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 deleted file mode 100644 index 4e2517838..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents/project/acp.py.j2 +++ /dev/null @@ -1,151 +0,0 @@ -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 - - user_prompt = params.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 deleted file mode 100644 index 34e04e6a4..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents/pyproject.toml.j2 +++ /dev/null @@ -1,32 +0,0 @@ -[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 deleted file mode 100644 index 0b8ae19b3..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents/requirements.txt.j2 +++ /dev/null @@ -1,5 +0,0 @@ -# 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 deleted file mode 100644 index 7de4684f4..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents/test_agent.py.j2 +++ /dev/null @@ -1,70 +0,0 @@ -""" -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 deleted file mode 100644 index c2d7fca4d..000000000 --- a/src/agentex/lib/cli/templates/sync-pydantic-ai/.dockerignore.j2 +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 1e81b15dd..000000000 --- a/src/agentex/lib/cli/templates/sync-pydantic-ai/.env.example.j2 +++ /dev/null @@ -1,12 +0,0 @@ -# {{ 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 deleted file mode 100644 index 582434ac9..000000000 --- a/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile-uv.j2 +++ /dev/null @@ -1,47 +0,0 @@ -# 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 {{ project_path_from_build_root }}/uv.lock ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --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 --locked --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 deleted file mode 100644 index 4d9f41d45..000000000 --- a/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile.j2 +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index a8ad10799..000000000 --- a/src/agentex/lib/cli/templates/sync-pydantic-ai/README.md.j2 +++ /dev/null @@ -1,313 +0,0 @@ -# {{ 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 - user_message = params.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 deleted file mode 100644 index d8c10a65a..000000000 --- a/src/agentex/lib/cli/templates/sync-pydantic-ai/dev.ipynb.j2 +++ /dev/null @@ -1,167 +0,0 @@ -{ - "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 deleted file mode 100644 index 73924abdd..000000000 --- a/src/agentex/lib/cli/templates/sync-pydantic-ai/environments.yaml.j2 +++ /dev/null @@ -1,53 +0,0 @@ -# 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 deleted file mode 100644 index 965769233..000000000 --- a/src/agentex/lib/cli/templates/sync-pydantic-ai/manifest.yaml.j2 +++ /dev/null @@ -1,115 +0,0 @@ -# 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 }} - - # 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 deleted file mode 100644 index 4925e847f..000000000 --- a/src/agentex/lib/cli/templates/sync-pydantic-ai/project/acp.py.j2 +++ /dev/null @@ -1,93 +0,0 @@ -"""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 create_agent - -import agentex.lib.adk as adk -from agentex.lib.adk import ( - create_pydantic_ai_tracing_handler, - convert_pydantic_ai_to_agentex_events, -) -from agentex.protocol.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.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__) - -# 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 - - user_message = params.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 tracing handler'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: - tracing_handler = create_pydantic_ai_tracing_handler( - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - task_id=task_id, - ) - async with agent.run_stream_events(user_message) as stream: - async for event in convert_pydantic_ai_to_agentex_events( - stream, tracing_handler=tracing_handler - ): - yield event 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 deleted file mode 100644 index b5b43f7ff..000000000 --- a/src/agentex/lib/cli/templates/sync-pydantic-ai/project/agent.py.j2 +++ /dev/null @@ -1,42 +0,0 @@ -"""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 deleted file mode 100644 index bab87942a..000000000 --- a/src/agentex/lib/cli/templates/sync-pydantic-ai/project/tools.py.j2 +++ /dev/null @@ -1,20 +0,0 @@ -"""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 deleted file mode 100644 index e3c57647f..000000000 --- a/src/agentex/lib/cli/templates/sync-pydantic-ai/pyproject.toml.j2 +++ /dev/null @@ -1,33 +0,0 @@ -[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 deleted file mode 100644 index 5a812a218..000000000 --- a/src/agentex/lib/cli/templates/sync-pydantic-ai/requirements.txt.j2 +++ /dev/null @@ -1,8 +0,0 @@ -# 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 deleted file mode 100644 index 7de4684f4..000000000 --- a/src/agentex/lib/cli/templates/sync-pydantic-ai/test_agent.py.j2 +++ /dev/null @@ -1,70 +0,0 @@ -""" -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 deleted file mode 100644 index c2d7fca4d..000000000 --- a/src/agentex/lib/cli/templates/sync/.dockerignore.j2 +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 015f49ef7..000000000 --- a/src/agentex/lib/cli/templates/sync/.env.example.j2 +++ /dev/null @@ -1,13 +0,0 @@ -# {{ 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 deleted file mode 100644 index 582434ac9..000000000 --- a/src/agentex/lib/cli/templates/sync/Dockerfile-uv.j2 +++ /dev/null @@ -1,47 +0,0 @@ -# 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 {{ project_path_from_build_root }}/uv.lock ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --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 --locked --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 deleted file mode 100644 index 4d9f41d45..000000000 --- a/src/agentex/lib/cli/templates/sync/Dockerfile.j2 +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index a8ad10799..000000000 --- a/src/agentex/lib/cli/templates/sync/README.md.j2 +++ /dev/null @@ -1,313 +0,0 @@ -# {{ 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 - user_message = params.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 deleted file mode 100644 index d8c10a65a..000000000 --- a/src/agentex/lib/cli/templates/sync/dev.ipynb.j2 +++ /dev/null @@ -1,167 +0,0 @@ -{ - "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 deleted file mode 100644 index 73924abdd..000000000 --- a/src/agentex/lib/cli/templates/sync/environments.yaml.j2 +++ /dev/null @@ -1,53 +0,0 @@ -# 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 deleted file mode 100644 index 965769233..000000000 --- a/src/agentex/lib/cli/templates/sync/manifest.yaml.j2 +++ /dev/null @@ -1,115 +0,0 @@ -# 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 }} - - # 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 deleted file mode 100644 index ce5069a4c..000000000 --- a/src/agentex/lib/cli/templates/sync/project/acp.py.j2 +++ /dev/null @@ -1,26 +0,0 @@ -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""" - 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: {params.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 deleted file mode 100644 index 34e04e6a4..000000000 --- a/src/agentex/lib/cli/templates/sync/pyproject.toml.j2 +++ /dev/null @@ -1,32 +0,0 @@ -[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 deleted file mode 100644 index 0b8ae19b3..000000000 --- a/src/agentex/lib/cli/templates/sync/requirements.txt.j2 +++ /dev/null @@ -1,5 +0,0 @@ -# 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 deleted file mode 100644 index 7de4684f4..000000000 --- a/src/agentex/lib/cli/templates/sync/test_agent.py.j2 +++ /dev/null @@ -1,70 +0,0 @@ -""" -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-langgraph/.dockerignore.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/.dockerignore.j2 deleted file mode 100644 index c2d7fca4d..000000000 --- a/src/agentex/lib/cli/templates/temporal-langgraph/.dockerignore.j2 +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 015f49ef7..000000000 --- a/src/agentex/lib/cli/templates/temporal-langgraph/.env.example.j2 +++ /dev/null @@ -1,13 +0,0 @@ -# {{ 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 deleted file mode 100644 index 2a3f1108b..000000000 --- a/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile-uv.j2 +++ /dev/null @@ -1,55 +0,0 @@ -# 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 {{ project_path_from_build_root }}/uv.lock ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --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 --locked --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 deleted file mode 100644 index ba47485a9..000000000 --- a/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile.j2 +++ /dev/null @@ -1,48 +0,0 @@ -# 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 deleted file mode 100644 index e8af5a90b..000000000 --- a/src/agentex/lib/cli/templates/temporal-langgraph/README.md.j2 +++ /dev/null @@ -1,121 +0,0 @@ -# {{ 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 deleted file mode 100644 index d3a68303f..000000000 --- a/src/agentex/lib/cli/templates/temporal-langgraph/dev.ipynb.j2 +++ /dev/null @@ -1,126 +0,0 @@ -{ - "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 deleted file mode 100644 index a3df5e228..000000000 --- a/src/agentex/lib/cli/templates/temporal-langgraph/environments.yaml.j2 +++ /dev/null @@ -1,64 +0,0 @@ -# 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 deleted file mode 100644 index 18cffd54a..000000000 --- a/src/agentex/lib/cli/templates/temporal-langgraph/manifest.yaml.j2 +++ /dev/null @@ -1,140 +0,0 @@ -# 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 }}" - - # 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 deleted file mode 100644 index c01f8831c..000000000 --- a/src/agentex/lib/cli/templates/temporal-langgraph/project/acp.py.j2 +++ /dev/null @@ -1,42 +0,0 @@ -"""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 deleted file mode 100644 index feb8051bb..000000000 --- a/src/agentex/lib/cli/templates/temporal-langgraph/project/graph.py.j2 +++ /dev/null @@ -1,165 +0,0 @@ -"""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 deleted file mode 100644 index 9dc45a4a0..000000000 --- a/src/agentex/lib/cli/templates/temporal-langgraph/project/run_worker.py.j2 +++ /dev/null @@ -1,50 +0,0 @@ -"""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 deleted file mode 100644 index 35660ad9b..000000000 --- a/src/agentex/lib/cli/templates/temporal-langgraph/project/tools.py.j2 +++ /dev/null @@ -1,57 +0,0 @@ -"""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 deleted file mode 100644 index d1621fb8c..000000000 --- a/src/agentex/lib/cli/templates/temporal-langgraph/project/workflow.py.j2 +++ /dev/null @@ -1,259 +0,0 @@ -"""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}") - self._turn_number += 1 - user_text = params.event.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 deleted file mode 100644 index 125ce704c..000000000 --- a/src/agentex/lib/cli/templates/temporal-langgraph/pyproject.toml.j2 +++ /dev/null @@ -1,42 +0,0 @@ -[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 deleted file mode 100644 index a499fc17c..000000000 --- a/src/agentex/lib/cli/templates/temporal-langgraph/requirements.txt.j2 +++ /dev/null @@ -1,18 +0,0 @@ -# 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 deleted file mode 100644 index 2d28e44d4..000000000 --- a/src/agentex/lib/cli/templates/temporal-langgraph/test_agent.py.j2 +++ /dev/null @@ -1,147 +0,0 @@ -""" -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 deleted file mode 100644 index c2d7fca4d..000000000 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/.dockerignore.j2 +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 015f49ef7..000000000 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/.env.example.j2 +++ /dev/null @@ -1,13 +0,0 @@ -# {{ 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 deleted file mode 100644 index 625592d31..000000000 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile-uv.j2 +++ /dev/null @@ -1,55 +0,0 @@ -# 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 {{ project_path_from_build_root }}/uv.lock ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --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 --locked --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 deleted file mode 100644 index 4c1798c42..000000000 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile.j2 +++ /dev/null @@ -1,48 +0,0 @@ -# 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 deleted file mode 100644 index 50dcdf164..000000000 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/README.md.j2 +++ /dev/null @@ -1,224 +0,0 @@ -# {{ 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 deleted file mode 100644 index d3a68303f..000000000 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/dev.ipynb.j2 +++ /dev/null @@ -1,126 +0,0 @@ -{ - "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 deleted file mode 100644 index a3df5e228..000000000 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/environments.yaml.j2 +++ /dev/null @@ -1,64 +0,0 @@ -# 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 deleted file mode 100644 index ee5e473d2..000000000 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/manifest.yaml.j2 +++ /dev/null @@ -1,140 +0,0 @@ -# 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 }} - - # 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 deleted file mode 100644 index 93ed6e659..000000000 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/project/acp.py.j2 +++ /dev/null @@ -1,86 +0,0 @@ -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 deleted file mode 100644 index 907cb287a..000000000 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/project/activities.py.j2 +++ /dev/null @@ -1,116 +0,0 @@ -""" -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 deleted file mode 100644 index 2516d3e0b..000000000 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/project/run_worker.py.j2 +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index 2b81bb335..000000000 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/project/workflow.py.j2 +++ /dev/null @@ -1,176 +0,0 @@ -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}") - - # 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": params.event.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 deleted file mode 100644 index a1ebab933..000000000 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/pyproject.toml.j2 +++ /dev/null @@ -1,35 +0,0 @@ -[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 deleted file mode 100644 index d4bd7a0fa..000000000 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/requirements.txt.j2 +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index ee71f177c..000000000 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/test_agent.py.j2 +++ /dev/null @@ -1,147 +0,0 @@ -""" -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 deleted file mode 100644 index c2d7fca4d..000000000 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/.dockerignore.j2 +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 1e81b15dd..000000000 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/.env.example.j2 +++ /dev/null @@ -1,12 +0,0 @@ -# {{ 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 deleted file mode 100644 index 625592d31..000000000 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile-uv.j2 +++ /dev/null @@ -1,55 +0,0 @@ -# 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 {{ project_path_from_build_root }}/uv.lock ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --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 --locked --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 deleted file mode 100644 index 4c1798c42..000000000 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile.j2 +++ /dev/null @@ -1,48 +0,0 @@ -# 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 deleted file mode 100644 index ca1abcc7f..000000000 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/README.md.j2 +++ /dev/null @@ -1,227 +0,0 @@ -# {{ 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 deleted file mode 100644 index d3a68303f..000000000 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/dev.ipynb.j2 +++ /dev/null @@ -1,126 +0,0 @@ -{ - "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 deleted file mode 100644 index a3df5e228..000000000 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/environments.yaml.j2 +++ /dev/null @@ -1,64 +0,0 @@ -# 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 deleted file mode 100644 index ee5e473d2..000000000 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/manifest.yaml.j2 +++ /dev/null @@ -1,140 +0,0 @@ -# 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 }} - - # 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 deleted file mode 100644 index dde726905..000000000 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/acp.py.j2 +++ /dev/null @@ -1,35 +0,0 @@ -"""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 deleted file mode 100644 index 0aa958118..000000000 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/agent.py.j2 +++ /dev/null @@ -1,116 +0,0 @@ -"""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 to Redis using the ``stream_pydantic_ai_events`` helper. -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.adk import ( - stream_pydantic_ai_events, - create_pydantic_ai_tracing_handler, -) - -# 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). - """ - tracing_handler = create_pydantic_ai_tracing_handler( - trace_id=run_context.deps.task_id, - parent_span_id=run_context.deps.parent_span_id, - task_id=run_context.deps.task_id, - ) - await stream_pydantic_ai_events( - events, - run_context.deps.task_id, - tracing_handler=tracing_handler, - ) - - -# 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 deleted file mode 100644 index 29c4c7aa5..000000000 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/run_worker.py.j2 +++ /dev/null @@ -1,48 +0,0 @@ -"""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 deleted file mode 100644 index bab87942a..000000000 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/tools.py.j2 +++ /dev/null @@ -1,20 +0,0 @@ -"""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 deleted file mode 100644 index 66a91d7a8..000000000 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/workflow.py.j2 +++ /dev/null @@ -1,146 +0,0 @@ -"""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}") - 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() 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( - 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/src/agentex/lib/cli/templates/temporal-pydantic-ai/pyproject.toml.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/pyproject.toml.j2 deleted file mode 100644 index e95df9e7b..000000000 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/pyproject.toml.j2 +++ /dev/null @@ -1,35 +0,0 @@ -[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 deleted file mode 100644 index b2c95f02f..000000000 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/requirements.txt.j2 +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index ee71f177c..000000000 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/test_agent.py.j2 +++ /dev/null @@ -1,147 +0,0 @@ -""" -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 deleted file mode 100644 index c2d7fca4d..000000000 --- a/src/agentex/lib/cli/templates/temporal/.dockerignore.j2 +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 015f49ef7..000000000 --- a/src/agentex/lib/cli/templates/temporal/.env.example.j2 +++ /dev/null @@ -1,13 +0,0 @@ -# {{ 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 deleted file mode 100644 index 625592d31..000000000 --- a/src/agentex/lib/cli/templates/temporal/Dockerfile-uv.j2 +++ /dev/null @@ -1,55 +0,0 @@ -# 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 {{ project_path_from_build_root }}/uv.lock ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --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 --locked --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 deleted file mode 100644 index 4c1798c42..000000000 --- a/src/agentex/lib/cli/templates/temporal/Dockerfile.j2 +++ /dev/null @@ -1,48 +0,0 @@ -# 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 deleted file mode 100644 index 7dc8a7dc1..000000000 --- a/src/agentex/lib/cli/templates/temporal/README.md.j2 +++ /dev/null @@ -1,353 +0,0 @@ -# {{ 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 deleted file mode 100644 index d3a68303f..000000000 --- a/src/agentex/lib/cli/templates/temporal/dev.ipynb.j2 +++ /dev/null @@ -1,126 +0,0 @@ -{ - "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 deleted file mode 100644 index a3df5e228..000000000 --- a/src/agentex/lib/cli/templates/temporal/environments.yaml.j2 +++ /dev/null @@ -1,64 +0,0 @@ -# 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 deleted file mode 100644 index ee5e473d2..000000000 --- a/src/agentex/lib/cli/templates/temporal/manifest.yaml.j2 +++ /dev/null @@ -1,140 +0,0 @@ -# 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 }} - - # 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 deleted file mode 100644 index ec06135c6..000000000 --- a/src/agentex/lib/cli/templates/temporal/project/acp.py.j2 +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index 6144b2343..000000000 --- a/src/agentex/lib/cli/templates/temporal/project/activities.py.j2 +++ /dev/null @@ -1,77 +0,0 @@ -""" -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 deleted file mode 100644 index 1721abacf..000000000 --- a/src/agentex/lib/cli/templates/temporal/project/run_worker.py.j2 +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 56db5abf3..000000000 --- a/src/agentex/lib/cli/templates/temporal/project/workflow.py.j2 +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index 9e157aa48..000000000 --- a/src/agentex/lib/cli/templates/temporal/pyproject.toml.j2 +++ /dev/null @@ -1,34 +0,0 @@ -[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 deleted file mode 100644 index 0b8ae19b3..000000000 --- a/src/agentex/lib/cli/templates/temporal/requirements.txt.j2 +++ /dev/null @@ -1,5 +0,0 @@ -# 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 deleted file mode 100644 index ee71f177c..000000000 --- a/src/agentex/lib/cli/templates/temporal/test_agent.py.j2 +++ /dev/null @@ -1,147 +0,0 @@ -""" -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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/cli/utils/auth_utils.py b/src/agentex/lib/cli/utils/auth_utils.py deleted file mode 100644 index a323d1e25..000000000 --- a/src/agentex/lib/cli/utils/auth_utils.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -import json -import base64 -from typing import Any, Dict - -from agentex.lib.sdk.config.agent_manifest import AgentManifest -from agentex.lib.sdk.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 deleted file mode 100644 index 43b3fba62..000000000 --- a/src/agentex/lib/cli/utils/cli_utils.py +++ /dev/null @@ -1,16 +0,0 @@ -from __future__ import annotations - -import typer -from rich.console import Console - -console = Console() - - -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 deleted file mode 100644 index 5ad2471fe..000000000 --- a/src/agentex/lib/cli/utils/credential_utils.py +++ /dev/null @@ -1,103 +0,0 @@ -import subprocess - -from rich.prompt import Prompt, Confirm -from rich.console import Console - -from agentex.lib.types.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 deleted file mode 100644 index efd41b6c5..000000000 --- a/src/agentex/lib/cli/utils/exceptions.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 4213233cd..000000000 --- a/src/agentex/lib/cli/utils/kubectl_utils.py +++ /dev/null @@ -1,137 +0,0 @@ -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 deleted file mode 100644 index 0a67a31e4..000000000 --- a/src/agentex/lib/cli/utils/kubernetes_secrets_utils.py +++ /dev/null @@ -1,187 +0,0 @@ -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 deleted file mode 100644 index 7aee3a65d..000000000 --- a/src/agentex/lib/cli/utils/path_utils.py +++ /dev/null @@ -1,145 +0,0 @@ -from __future__ import annotations - -from typing import Dict -from pathlib import Path - -from agentex.lib.utils.logging import make_logger -from agentex.lib.sdk.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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/core/adapters/__init__.py b/src/agentex/lib/core/adapters/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/core/adapters/llm/__init__.py b/src/agentex/lib/core/adapters/llm/__init__.py deleted file mode 100644 index 8b1378917..000000000 --- a/src/agentex/lib/core/adapters/llm/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/agentex/lib/core/adapters/llm/adapter_litellm.py b/src/agentex/lib/core/adapters/llm/adapter_litellm.py deleted file mode 100644 index 7935f5f49..000000000 --- a/src/agentex/lib/core/adapters/llm/adapter_litellm.py +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 31098246e..000000000 --- a/src/agentex/lib/core/adapters/llm/adapter_sgp.py +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index 4daaade45..000000000 --- a/src/agentex/lib/core/adapters/llm/port.py +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 8446d67f1..000000000 --- a/src/agentex/lib/core/adapters/streams/adapter_redis.py +++ /dev/null @@ -1,184 +0,0 @@ -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 deleted file mode 100644 index 31b5eda61..000000000 --- a/src/agentex/lib/core/adapters/streams/port.py +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index 8b1378917..000000000 --- a/src/agentex/lib/core/clients/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/agentex/lib/core/clients/temporal/__init__.py b/src/agentex/lib/core/clients/temporal/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/core/clients/temporal/temporal_client.py b/src/agentex/lib/core/clients/temporal/temporal_client.py deleted file mode 100644 index ca17d30ff..000000000 --- a/src/agentex/lib/core/clients/temporal/temporal_client.py +++ /dev/null @@ -1,215 +0,0 @@ -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 -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, - 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, -} - - -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, - retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, - task_timeout: timedelta = timedelta(seconds=10), - execution_timeout: timedelta = timedelta(seconds=86400), - **kwargs: Any, - ) -> str: - 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], - **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 deleted file mode 100644 index 8ce596d77..000000000 --- a/src/agentex/lib/core/clients/temporal/types.py +++ /dev/null @@ -1,49 +0,0 @@ -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 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 deleted file mode 100644 index 95319720a..000000000 --- a/src/agentex/lib/core/clients/temporal/utils.py +++ /dev/null @@ -1,155 +0,0 @@ -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 - -# 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, - } - - 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/observability/__init__.py b/src/agentex/lib/core/observability/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/core/observability/llm_metrics.py b/src/agentex/lib/core/observability/llm_metrics.py deleted file mode 100644 index b15e83824..000000000 --- a/src/agentex/lib/core/observability/llm_metrics.py +++ /dev/null @@ -1,121 +0,0 @@ -"""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 deleted file mode 100644 index fce4b29ba..000000000 --- a/src/agentex/lib/core/observability/llm_metrics_hooks.py +++ /dev/null @@ -1,57 +0,0 @@ -"""``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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/core/observability/tests/test_llm_metrics.py b/src/agentex/lib/core/observability/tests/test_llm_metrics.py deleted file mode 100644 index d8ab62eba..000000000 --- a/src/agentex/lib/core/observability/tests/test_llm_metrics.py +++ /dev/null @@ -1,83 +0,0 @@ -"""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 deleted file mode 100644 index a2cef95b8..000000000 --- a/src/agentex/lib/core/observability/tests/test_llm_metrics_hooks.py +++ /dev/null @@ -1,215 +0,0 @@ -"""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 deleted file mode 100644 index aab4fbfed..000000000 --- a/src/agentex/lib/core/observability/tests/test_tracing_metrics.py +++ /dev/null @@ -1,100 +0,0 @@ -"""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 deleted file mode 100644 index 6c50c599f..000000000 --- a/src/agentex/lib/core/observability/tests/test_tracing_metrics_recording.py +++ /dev/null @@ -1,143 +0,0 @@ -"""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 deleted file mode 100644 index 74960cc4a..000000000 --- a/src/agentex/lib/core/observability/tracing_metrics.py +++ /dev/null @@ -1,164 +0,0 @@ -"""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 deleted file mode 100644 index 4fd8632b0..000000000 --- a/src/agentex/lib/core/observability/tracing_metrics_recording.py +++ /dev/null @@ -1,153 +0,0 @@ -"""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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/core/services/adk/__init__.py b/src/agentex/lib/core/services/adk/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/core/services/adk/acp/__init__.py b/src/agentex/lib/core/services/adk/acp/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/core/services/adk/acp/acp.py b/src/agentex/lib/core/services/adk/acp/acp.py deleted file mode 100644 index 956e1b5db..000000000 --- a/src/agentex/lib/core/services/adk/acp/acp.py +++ /dev/null @@ -1,285 +0,0 @@ -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 deleted file mode 100644 index 54ee4f72f..000000000 --- a/src/agentex/lib/core/services/adk/agent_task_tracker.py +++ /dev/null @@ -1,87 +0,0 @@ -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 deleted file mode 100644 index 1d26b9d56..000000000 --- a/src/agentex/lib/core/services/adk/agents.py +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index fbed9e5af..000000000 --- a/src/agentex/lib/core/services/adk/events.py +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index 929100eb1..000000000 --- a/src/agentex/lib/core/services/adk/messages.py +++ /dev/null @@ -1,168 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/core/services/adk/providers/litellm.py b/src/agentex/lib/core/services/adk/providers/litellm.py deleted file mode 100644 index a511aa7b8..000000000 --- a/src/agentex/lib/core/services/adk/providers/litellm.py +++ /dev/null @@ -1,251 +0,0 @@ -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__) - - -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: - span.output = streaming_context.task_message.model_dump() - 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") - - trace = self.tracer.trace(trace_id) - async with trace.span( - parent_id=parent_span_id, - name="chat_completion_stream", - input=llm_config.model_dump(), - ) as span: - # Direct streaming outside temporal - yield each chunk as it comes - chunks: list[Completion] = [] - async for chunk in self.llm_gateway.acompletion_stream(**llm_config.model_dump()): - chunks.append(chunk) - yield chunk - if span: - 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 - - trace = self.tracer.trace(trace_id) - async with trace.span( - parent_id=parent_span_id, - name="chat_completion_stream_auto_send", - input=llm_config.model_dump(), - ) 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(**llm_config.model_dump()): - heartbeat_if_in_workflow("chat completion streaming") - 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") - - # Store the chunk for final message assembly - chunks.append(response) - - # 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: - span.output = streaming_context.task_message.model_dump() - - 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 deleted file mode 100644 index 75e507d8a..000000000 --- a/src/agentex/lib/core/services/adk/providers/openai.py +++ /dev/null @@ -1,1104 +0,0 @@ -# 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 ( - ResponseCompletedEvent, - ResponseTextDeltaEvent, - ResponseFunctionToolCall, - ResponseFunctionWebSearch, - ResponseOutputItemDoneEvent, - ResponseCodeInterpreterToolCall, - ResponseReasoningSummaryPartDoneEvent, - ResponseReasoningSummaryPartAddedEvent, - ResponseReasoningSummaryTextDeltaEvent, -) - -# 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.types.task_message_delta import ( - TextDelta, - ReasoningSummaryDelta, -) -from agentex.types.task_message_update import ( - StreamTaskMessageFull, - StreamTaskMessageDelta, -) -from agentex.types.task_message_content import ( - TextContent, - ReasoningContent, - ToolRequestContent, - ToolResponseContent, -) -from agentex.lib.core.services.adk.streaming import ( - StreamingService, - StreamingTaskMessageContext, -) - -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: - 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 - - 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: - 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, - } - - 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: - 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 - - 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, # noqa: ARG002 - 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") - - tool_call_map: dict[str, ResponseFunctionToolCall] = {} - - 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") - - # Consume the workflow-supplied created_at on the FIRST message - # opened by this activity (whichever streaming context opens first - # for this turn). That's the message that races the workflow's - # user-echo at the server. Subsequent messages in the same turn are - # separated by network/processing latency and rely on the server's - # wall clock. - _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 - if max_turns is not None: - result = Runner.run_streamed(starting_agent=agent, input=input_list, max_turns=max_turns) - else: - result = Runner.run_streamed(starting_agent=agent, input=input_list) - - item_id_to_streaming_context: dict[str, StreamingTaskMessageContext] = {} - unclosed_item_ids: set[str] = set() - # Simple string to accumulate reasoning summary - current_reasoning_summary: str = "" - - try: - # Process streaming events with TaskMessage creation - async for event in result.stream_events(): - heartbeat_if_in_workflow("processing stream event with auto send") - - if event.type == "run_item_stream_event": - if event.item.type == "tool_call_item": - tool_call_item = event.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 (immediate completion) - 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: - # 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, - type="full", - ), - ) - - elif event.item.type == "tool_call_output_item": - tool_output_item = event.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 (immediate completion) - 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: - # 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, - type="full", - ), - ) - - elif event.type == "raw_response_event": - if isinstance(event.data, ResponseTextDeltaEvent): - # Handle text delta - item_id = event.data.item_id - - # Check if we already have a streaming context for this item - if item_id not in item_id_to_streaming_context: - # Create a new streaming context for this item - streaming_context = self.streaming_service.streaming_task_message_context( - task_id=task_id, - initial_content=TextContent( - author="agent", - content="", - ), - created_at=_take_created_at(), - ) - # Open the streaming context - item_id_to_streaming_context[item_id] = await streaming_context.open() - unclosed_item_ids.add(item_id) - else: - streaming_context = item_id_to_streaming_context[item_id] - - # Stream the delta through the streaming service - 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", - ), - ) - # Reasoning step one: new summary part added - elif isinstance(event.data, ResponseReasoningSummaryPartAddedEvent): - # We need to create a new streaming context for this reasoning item - item_id = event.data.item_id - - # Reset the reasoning summary string - current_reasoning_summary = "" - - streaming_context = self.streaming_service.streaming_task_message_context( - task_id=task_id, - initial_content=ReasoningContent( - author="agent", - summary=[], - content=[], - type="reasoning", - style="active", - ), - created_at=_take_created_at(), - ) - - # Replace the existing streaming context (if it exists) - # Why do we replace? Cause all the reasoning parts use the same item_id! - item_id_to_streaming_context[item_id] = await streaming_context.open() - unclosed_item_ids.add(item_id) - - # Reasoning step two: handling summary text delta - elif isinstance(event.data, ResponseReasoningSummaryTextDeltaEvent): - # Accumulate the delta into the string - current_reasoning_summary += event.data.delta - streaming_context = item_id_to_streaming_context[item_id] - - # Stream the summary delta through the streaming service - await streaming_context.stream_update( - update=StreamTaskMessageDelta( - parent_task_message=streaming_context.task_message, - delta=ReasoningSummaryDelta( - summary_index=event.data.summary_index, - summary_delta=event.data.delta, - type="reasoning_summary", - ), - type="delta", - ), - ) - - # Reasoning step three: handling summary text done, closing the streaming context - elif isinstance(event.data, ResponseReasoningSummaryPartDoneEvent): - # Handle reasoning summary text completion - streaming_context = item_id_to_streaming_context[item_id] - - # Create the complete reasoning content with the accumulated summary - complete_reasoning_content = ReasoningContent( - author="agent", - summary=[current_reasoning_summary], - content=[], - type="reasoning", - style="static", - ) - - # Send a full message update with the complete reasoning content - await streaming_context.stream_update( - update=StreamTaskMessageFull( - parent_task_message=streaming_context.task_message, - content=complete_reasoning_content, - type="full", - ), - ) - - await streaming_context.close() - unclosed_item_ids.discard(item_id) - - elif isinstance(event.data, ResponseOutputItemDoneEvent): - # Handle item completion - item_id = event.data.item.id - - # Finish the streaming context (sends DONE event and updates message) - if item_id in item_id_to_streaming_context: - streaming_context = item_id_to_streaming_context[item_id] - await streaming_context.close() - if item_id in unclosed_item_ids: - unclosed_item_ids.remove(item_id) - - elif isinstance(event.data, ResponseCompletedEvent): - # All items complete, finish all remaining streaming contexts for this session - # Create a copy to avoid modifying set during iteration - remaining_items = list(unclosed_item_ids) - for item_id in remaining_items: - if ( - item_id in unclosed_item_ids and item_id in item_id_to_streaming_context - ): # Check if still unclosed - streaming_context = item_id_to_streaming_context[item_id] - await streaming_context.close() - unclosed_item_ids.discard(item_id) - - 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 - - finally: - # Cleanup: ensure all streaming contexts for this session are properly finished - # Create a copy to avoid modifying set during iteration - remaining_items = list(unclosed_item_ids) - for item_id in remaining_items: - if ( - item_id in unclosed_item_ids and item_id in item_id_to_streaming_context - ): # Check if still unclosed - streaming_context = item_id_to_streaming_context[item_id] - await streaming_context.close() - unclosed_item_ids.discard(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/src/agentex/lib/core/services/adk/providers/sgp.py b/src/agentex/lib/core/services/adk/providers/sgp.py deleted file mode 100644 index 69f765aa7..000000000 --- a/src/agentex/lib/core/services/adk/providers/sgp.py +++ /dev/null @@ -1,101 +0,0 @@ -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 deleted file mode 100644 index 6b8364530..000000000 --- a/src/agentex/lib/core/services/adk/state.py +++ /dev/null @@ -1,126 +0,0 @@ -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: - state_model = await self._agentex_client.states.update( - state_id=state_id, - task_id=task_id, - agent_id=agent_id, - state=state, - ) - 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 deleted file mode 100644 index 7215f084c..000000000 --- a/src/agentex/lib/core/services/adk/streaming.py +++ /dev/null @@ -1,550 +0,0 @@ -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: - 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 close(self) -> TaskMessage: - """Close the streaming context.""" - if not self.task_message: - raise ValueError("Context not properly initialized - no task message") - - if self._is_closed: - return self.task_message # Already done - - # Drain any buffered deltas before announcing DONE so consumers see the - # full sequence in order. - if self._buffer is not None: - await self._buffer.close() - self._buffer = None - - # 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 - - 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 deleted file mode 100644 index 7748799e4..000000000 --- a/src/agentex/lib/core/services/adk/tasks.py +++ /dev/null @@ -1,223 +0,0 @@ -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 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 deleted file mode 100644 index 77efffd9e..000000000 --- a/src/agentex/lib/core/services/adk/tracing.py +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/core/services/adk/utils/templating.py b/src/agentex/lib/core/services/adk/utils/templating.py deleted file mode 100644 index 1cd0ebbfc..000000000 --- a/src/agentex/lib/core/services/adk/utils/templating.py +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/core/temporal/activities/__init__.py b/src/agentex/lib/core/temporal/activities/__init__.py deleted file mode 100644 index 4660afdde..000000000 --- a/src/agentex/lib/core/temporal/activities/__init__.py +++ /dev/null @@ -1,218 +0,0 @@ -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.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 deleted file mode 100644 index 53ec3a451..000000000 --- a/src/agentex/lib/core/temporal/activities/activity_helpers.py +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/core/temporal/activities/adk/acp/__init__.py b/src/agentex/lib/core/temporal/activities/adk/acp/__init__.py deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 634892ec5..000000000 --- a/src/agentex/lib/core/temporal/activities/adk/acp/acp_activities.py +++ /dev/null @@ -1,108 +0,0 @@ -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 deleted file mode 100644 index e20e4dd1d..000000000 --- a/src/agentex/lib/core/temporal/activities/adk/agent_task_tracker_activities.py +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index 7b7e2b7af..000000000 --- a/src/agentex/lib/core/temporal/activities/adk/agents_activities.py +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 59d5b3601..000000000 --- a/src/agentex/lib/core/temporal/activities/adk/events_activities.py +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index 3ae5aaf5b..000000000 --- a/src/agentex/lib/core/temporal/activities/adk/messages_activities.py +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index d1c052a23..000000000 --- a/src/agentex/lib/core/temporal/activities/adk/providers/litellm_activities.py +++ /dev/null @@ -1,76 +0,0 @@ -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 deleted file mode 100644 index 5f81b20d5..000000000 --- a/src/agentex/lib/core/temporal/activities/adk/providers/openai_activities.py +++ /dev/null @@ -1,689 +0,0 @@ -# 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 deleted file mode 100644 index 3905eb166..000000000 --- a/src/agentex/lib/core/temporal/activities/adk/providers/sgp_activities.py +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index 4eaf83fb2..000000000 --- a/src/agentex/lib/core/temporal/activities/adk/state_activities.py +++ /dev/null @@ -1,87 +0,0 @@ -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 deleted file mode 100644 index 2d9faf352..000000000 --- a/src/agentex/lib/core/temporal/activities/adk/streaming_activities.py +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 38eecd447..000000000 --- a/src/agentex/lib/core/temporal/activities/adk/tasks_activities.py +++ /dev/null @@ -1,139 +0,0 @@ -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" - 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.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 deleted file mode 100644 index aec541afe..000000000 --- a/src/agentex/lib/core/temporal/activities/adk/tracing_activities.py +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index a2cc4ff10..000000000 --- a/src/agentex/lib/core/temporal/activities/adk/utils/templating_activities.py +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 52ab6eac7..000000000 --- a/src/agentex/lib/core/temporal/plugins/__init__.py +++ /dev/null @@ -1,58 +0,0 @@ -"""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, - ... TemporalTracingModelProvider, - ... 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, - TemporalTracingModelProvider, - TemporalStreamingModelProvider, - streaming_task_id, - streaming_trace_id, - stream_lifecycle_content, - streaming_parent_span_id, -) - -__all__ = [ - "TemporalStreamingModel", - "TemporalStreamingModelProvider", - "TemporalTracingModelProvider", - "ContextInterceptor", - "streaming_task_id", - "streaming_trace_id", - "streaming_parent_span_id", - "TemporalStreamingHooks", - "stream_lifecycle_content", -] \ No newline at end of file diff --git a/src/agentex/lib/core/temporal/plugins/claude_agents/__init__.py b/src/agentex/lib/core/temporal/plugins/claude_agents/__init__.py deleted file mode 100644 index fd40545ec..000000000 --- a/src/agentex/lib/core/temporal/plugins/claude_agents/__init__.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Claude Agents SDK integration with Temporal. - -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 deleted file mode 100644 index 57313d8a9..000000000 --- a/src/agentex/lib/core/temporal/plugins/claude_agents/activities.py +++ /dev/null @@ -1,418 +0,0 @@ -"""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 deleted file mode 100644 index 39c086515..000000000 --- a/src/agentex/lib/core/temporal/plugins/claude_agents/hooks/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""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 deleted file mode 100644 index 71acb0b4b..000000000 --- a/src/agentex/lib/core/temporal/plugins/claude_agents/hooks/hooks.py +++ /dev/null @@ -1,212 +0,0 @@ -"""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 deleted file mode 100644 index c0d414a23..000000000 --- a/src/agentex/lib/core/temporal/plugins/claude_agents/message_handler.py +++ /dev/null @@ -1,178 +0,0 @@ -"""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 deleted file mode 100644 index 5497c4666..000000000 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/README.md +++ /dev/null @@ -1,750 +0,0 @@ -# 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 deleted file mode 100644 index def67c9af..000000000 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/__init__.py +++ /dev/null @@ -1,84 +0,0 @@ -"""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.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_tracing_model import ( - TemporalTracingModelProvider, -) -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", - "TemporalTracingModelProvider", - "ContextInterceptor", - "streaming_task_id", - "streaming_trace_id", - "streaming_parent_span_id", - "TemporalStreamingHooks", - "stream_lifecycle_content", -] \ No newline at end of file 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 deleted file mode 100644 index 7a01e3f50..000000000 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""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 deleted file mode 100644 index c65ae3c8b..000000000 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/activities.py +++ /dev/null @@ -1,81 +0,0 @@ -"""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 deleted file mode 100644 index 758b0db27..000000000 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/hooks.py +++ /dev/null @@ -1,210 +0,0 @@ -"""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. -""" - -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__) - - -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: - - Tool requests (on_tool_start): Streams when a tool is about to be invoked - - Tool responses (on_tool_end): Streams the tool's execution result - - Agent handoffs (on_handoff): Streams when control transfers between agents - - 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) - - 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) - """ - - def __init__( - self, - task_id: str, - timeout: timedelta = timedelta(seconds=10), - ): - """Initialize the streaming hooks. - - Args: - task_id: AgentEx task ID for routing streamed events to the correct UI session - timeout: Timeout for streaming activity invocations (default: 10 seconds) - """ - super().__init__() - self.task_id = task_id - self.timeout = timeout - - @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 tool request when a tool starts execution. - - Extracts the tool_call_id and tool_arguments from the context and streams a - ToolRequestContent message to the UI showing that the tool is about to execute. - - Args: - context: The run context wrapper (will be a ToolContext with tool_call_id and tool_arguments) - agent: The agent executing the tool - tool: The tool being executed - """ - import json - - tool_context = context if isinstance(context, ToolContext) else None - tool_call_id = tool_context.tool_call_id if tool_context else f"call_{id(tool)}" - - # Extract tool arguments from context - tool_arguments = {} - if tool_context and hasattr(tool_context, 'tool_arguments'): - try: - # tool_arguments is a JSON string, parse it - tool_arguments = json.loads(tool_context.tool_arguments) - except (json.JSONDecodeError, TypeError): - # If parsing fails, log and use empty dict - logger.warning(f"Failed to parse tool arguments: {tool_context.tool_arguments}") - tool_arguments = {} - - 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, - ) - - @override - async def on_tool_end( - self, context: RunContextWrapper, agent: Agent, tool: Tool, result: str # noqa: ARG002 - ) -> None: - """Stream tool response when a tool completes execution. - - Extracts the tool_call_id and streams a ToolResponseContent message to the UI - showing the tool's execution result. - - Args: - context: The run context wrapper (will be 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_context = context if isinstance(context, ToolContext) else None - tool_call_id = ( - getattr(tool_context, "tool_call_id", f"call_{id(tool)}") - if tool_context - else f"call_{id(tool)}" - ) - - 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, - ) - - @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. - - Args: - context: The run context wrapper - from_agent: The agent transferring control - to_agent: The agent receiving control - """ - 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, - ) 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 deleted file mode 100644 index 47290ea41..000000000 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/interceptors/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -"""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 deleted file mode 100644 index 893f75f28..000000000 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/interceptors/context_interceptor.py +++ /dev/null @@ -1,153 +0,0 @@ -""" -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 deleted file mode 100644 index bb5dc97ed..000000000 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/models/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -"""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_tracing_model import ( - TemporalTracingModelProvider, - TemporalTracingResponsesModel, - TemporalTracingChatCompletionsModel, -) -from agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model import ( - TemporalStreamingModel, - TemporalStreamingModelProvider, -) - -__all__ = [ - "TemporalStreamingModel", - "TemporalStreamingModelProvider", - "TemporalTracingModelProvider", - "TemporalTracingResponsesModel", - "TemporalTracingChatCompletionsModel", -] \ No newline at end of file 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 deleted file mode 100644 index 7ccc6627a..000000000 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/models/temporal_streaming_model.py +++ /dev/null @@ -1,1121 +0,0 @@ -"""Custom Temporal Model Provider with streaming support for OpenAI agents.""" -from __future__ import annotations - -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.types.task_message_delta import TextDelta, ReasoningContentDelta, ReasoningSummaryDelta -from agentex.types.task_message_update import StreamTaskMessageFull, StreamTaskMessageDelta -from agentex.types.task_message_content import TextContent, ReasoningContent -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 - - -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 - - @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': - # Track the function call being streamed - function_calls_in_progress[output_index] = { - 'id': getattr(item, 'id', ''), - 'call_id': getattr(item, 'call_id', ''), - 'name': getattr(item, 'name', ''), - 'arguments': getattr(item, 'arguments', ''), - } - 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', '') - - if output_index in function_calls_in_progress: - function_calls_in_progress[output_index]['arguments'] += delta - 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) - - 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 - - # 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 - - # 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/models/temporal_tracing_model.py b/src/agentex/lib/core/temporal/plugins/openai_agents/models/temporal_tracing_model.py deleted file mode 100644 index e3d1b3804..000000000 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/models/temporal_tracing_model.py +++ /dev/null @@ -1,402 +0,0 @@ -"""Temporal-aware tracing model provider. - -This module provides model implementations that add AgentEx tracing to standard OpenAI models -when running in Temporal workflows/activities. It uses context variables set by the Temporal -context interceptor to access task_id, trace_id, and parent_span_id. - -The key innovation is that these are thin wrappers around the standard OpenAI models, -avoiding code duplication while adding tracing capabilities. -""" -from __future__ import annotations - -import logging -from typing import Any, List, Union, Optional, override - -from agents import ( - Tool, - Model, - Handoff, - ModelTracing, - ModelResponse, - ModelSettings, - OpenAIProvider, - TResponseInputItem, - AgentOutputSchemaBase, -) -from openai import AsyncOpenAI -from openai.types.responses import ResponsePromptParam -from agents.models.openai_responses import OpenAIResponsesModel -from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel - -from agentex.lib.core.tracing.tracer import AsyncTracer - -# Import AgentEx components -from agentex.lib.adk.utils._modules.client import create_async_agentex_client - -# Import context variables from the interceptor -from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ( - streaming_task_id, - streaming_trace_id, - streaming_parent_span_id, -) - -logger = logging.getLogger("agentex.temporal.tracing") - - -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 TemporalTracingModelProvider(OpenAIProvider): - """Model provider that returns OpenAI models wrapped with AgentEx tracing. - - This provider extends the standard OpenAIProvider to return models that add - tracing spans around model calls when running in Temporal activities with - the context interceptor enabled. - """ - - def __init__(self, openai_client: Optional[AsyncOpenAI] = None, **kwargs): - """Initialize the tracing model provider. - - Args: - openai_client: Optional custom AsyncOpenAI client. If provided, this client - will be used for all model calls. If not provided, OpenAIProvider - will create a default client. - **kwargs: All other arguments are passed to OpenAIProvider. - """ - # Pass openai_client to parent if provided - if openai_client is not None: - super().__init__(openai_client=openai_client, **kwargs) - else: - super().__init__(**kwargs) - - # Initialize tracer for all models - agentex_client = create_async_agentex_client() - self._tracer = AsyncTracer(agentex_client) - logger.info(f"[TemporalTracingModelProvider] Initialized with AgentEx tracer, custom_client={openai_client is not None}") - - @override - def get_model(self, model_name: Optional[str]) -> Model: - """Get a model wrapped with tracing capabilities. - - Args: - model_name: The name of the model to use - - Returns: - A model instance wrapped with tracing - """ - # Get the base model from the parent provider - base_model = super().get_model(model_name) - - # Wrap with appropriate tracing wrapper based on model type - if isinstance(base_model, OpenAIResponsesModel): - logger.info(f"[TemporalTracingModelProvider] Wrapping OpenAIResponsesModel '{model_name}' with tracing") - return TemporalTracingResponsesModel(base_model, self._tracer) # type: ignore[abstract] - elif isinstance(base_model, OpenAIChatCompletionsModel): - logger.info(f"[TemporalTracingModelProvider] Wrapping OpenAIChatCompletionsModel '{model_name}' with tracing") - return TemporalTracingChatCompletionsModel(base_model, self._tracer) # type: ignore[abstract] - else: - logger.warning(f"[TemporalTracingModelProvider] Unknown model type, returning without tracing: {type(base_model)}") - return base_model - - -class TemporalTracingResponsesModel(Model): - """Wrapper for OpenAIResponsesModel that adds AgentEx tracing. - - This is a thin wrapper that adds tracing spans around the base model's - get_response() method. It reads tracing context from ContextVars set by - the Temporal context interceptor. - """ - - def __init__(self, base_model: OpenAIResponsesModel, tracer: AsyncTracer): - """Initialize the tracing wrapper. - - Args: - base_model: The OpenAI Responses model to wrap - tracer: The AgentEx tracer to use - """ - self._base_model = base_model - self._tracer = tracer - # Expose the model name for compatibility - self.model = base_model.model - - @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: Optional[ResponsePromptParam] = None, - **kwargs, - ) -> ModelResponse: - """Get a response from the model with optional tracing. - - If tracing context is available from the interceptor, this wraps the - model call in a tracing span. Otherwise, it passes through to the - base model without tracing. - """ - # Try to get tracing context from ContextVars - task_id = streaming_task_id.get() - trace_id = streaming_trace_id.get() - parent_span_id = streaming_parent_span_id.get() - - # If we have tracing context, wrap with span - if trace_id and parent_span_id: - logger.debug(f"[TemporalTracingResponsesModel] Adding tracing span for task_id={task_id}, trace_id={trace_id}") - - trace = self._tracer.trace(trace_id) - - async with trace.span( - parent_id=parent_span_id, - name="model_get_response", - input={ - "model": str(self.model), - "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, - "has_output_schema": output_schema is not None, - "model_settings": { - "temperature": model_settings.temperature, - "max_tokens": model_settings.max_tokens, - "reasoning": model_settings.reasoning, - } if model_settings else None, - }, - ) as span: - try: - # Call the base model - response = await self._base_model.get_response( - 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, - conversation_id=conversation_id, # type: ignore[call-arg] - prompt=prompt, - **kwargs, - ) - - # Serialize response output items for span tracing - new_items = [] - final_output = None - - if hasattr(response, 'output') and response.output: - response_output = response.output if isinstance(response.output, list) else [response.output] - - 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 tracing model: {e}") - continue - - # Set span output with structured data - span.output = { # type: ignore[attr-defined] - "new_items": new_items, - "final_output": final_output, - } - - return response - - except Exception as e: - # Record error in span - span.error = str(e) # type: ignore[attr-defined] - raise - else: - # No tracing context, just pass through - logger.debug("[TemporalTracingResponsesModel] No tracing context available, calling base model directly") - return await self._base_model.get_response( - 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, - conversation_id=conversation_id, # type: ignore[call-arg] - prompt=prompt, - **kwargs, - ) - - -class TemporalTracingChatCompletionsModel(Model): - """Wrapper for OpenAIChatCompletionsModel that adds AgentEx tracing. - - This is a thin wrapper that adds tracing spans around the base model's - get_response() method. It reads tracing context from ContextVars set by - the Temporal context interceptor. - """ - - def __init__(self, base_model: OpenAIChatCompletionsModel, tracer: AsyncTracer): - """Initialize the tracing wrapper. - - Args: - base_model: The OpenAI ChatCompletions model to wrap - tracer: The AgentEx tracer to use - """ - self._base_model = base_model - self._tracer = tracer - # Expose the model name for compatibility - self.model = base_model.model - - @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, - **kwargs, - ) -> ModelResponse: - """Get a response from the model with optional tracing. - - If tracing context is available from the interceptor, this wraps the - model call in a tracing span. Otherwise, it passes through to the - base model without tracing. - """ - # Try to get tracing context from ContextVars - task_id = streaming_task_id.get() - trace_id = streaming_trace_id.get() - parent_span_id = streaming_parent_span_id.get() - - # If we have tracing context, wrap with span - if trace_id and parent_span_id: - logger.debug(f"[TemporalTracingChatCompletionsModel] Adding tracing span for task_id={task_id}, trace_id={trace_id}") - - trace = self._tracer.trace(trace_id) - - async with trace.span( - parent_id=parent_span_id, - name="model_get_response", - input={ - "model": str(self.model), - "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, - "has_output_schema": output_schema is not None, - "model_settings": { - "temperature": model_settings.temperature, - "max_tokens": model_settings.max_tokens, - } if model_settings else None, - }, - ) as span: - try: - # Call the base model - response = await self._base_model.get_response( - system_instructions=system_instructions, - input=input, - model_settings=model_settings, - tools=tools, - output_schema=output_schema, - handoffs=handoffs, - tracing=tracing, - **kwargs, - ) - - # Serialize response output items for span tracing - new_items = [] - final_output = None - - if hasattr(response, 'output') and response.output: - response_output = response.output if isinstance(response.output, list) else [response.output] - - 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 tracing model: {e}") - continue - - # Set span output with structured data - span.output = { # type: ignore[attr-defined] - "new_items": new_items, - "final_output": final_output, - } - - return response - - except Exception as e: - # Record error in span - span.error = str(e) # type: ignore[attr-defined] - raise - else: - # No tracing context, just pass through - logger.debug("[TemporalTracingChatCompletionsModel] No tracing context available, calling base model directly") - return await self._base_model.get_response( - system_instructions=system_instructions, - input=input, - model_settings=model_settings, - tools=tools, - output_schema=output_schema, - handoffs=handoffs, - tracing=tracing, - **kwargs, - ) \ No newline at end of file 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 deleted file mode 100644 index 0c635833b..000000000 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/tests/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -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 deleted file mode 100644 index aa7ad7b04..000000000 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/tests/conftest.py +++ /dev/null @@ -1,331 +0,0 @@ -""" -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 deleted file mode 100644 index 56a77c5cb..000000000 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_convert_tools.py +++ /dev/null @@ -1,61 +0,0 @@ -"""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_streaming_model.py b/src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_streaming_model.py deleted file mode 100644 index 97dda0e61..000000000 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_streaming_model.py +++ /dev/null @@ -1,1233 +0,0 @@ -""" -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, - ResponseOutputItemAddedEvent, - ResponseReasoningSummaryTextDeltaEvent, -) - - -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 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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/core/temporal/services/temporal_task_service.py b/src/agentex/lib/core/temporal/services/temporal_task_service.py deleted file mode 100644 index 9d66c0c1f..000000000 --- a/src/agentex/lib/core/temporal/services/temporal_task_service.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import annotations - -from typing import Any -from datetime import timedelta - -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 -from agentex.lib.environment_variables import EnvironmentVariables -from agentex.lib.core.clients.temporal.types import WorkflowState -from agentex.lib.core.temporal.types.workflow import SignalName -from agentex.lib.core.clients.temporal.temporal_client import TemporalClient - - -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 - """ - 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=timedelta(seconds=self._env_vars.WORKFLOW_EXECUTION_TIMEOUT_SECONDS), - ) - - 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: - 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 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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/core/temporal/types/workflow.py b/src/agentex/lib/core/temporal/types/workflow.py deleted file mode 100644 index 973bb52c2..000000000 --- a/src/agentex/lib/core/temporal/types/workflow.py +++ /dev/null @@ -1,5 +0,0 @@ -from enum import Enum - - -class SignalName(str, Enum): - RECEIVE_EVENT = "receive_event" diff --git a/src/agentex/lib/core/temporal/workers/__init__.py b/src/agentex/lib/core/temporal/workers/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/core/temporal/workers/worker.py b/src/agentex/lib/core/temporal/workers/worker.py deleted file mode 100644 index 253b6759f..000000000 --- a/src/agentex/lib/core/temporal/workers/worker.py +++ /dev/null @@ -1,283 +0,0 @@ -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 -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.environment_variables import EnvironmentVariables - -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, -) -> 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, - } - - 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))) - 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, - ): - 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.payload_codec = payload_codec - self.data_converter = data_converter - - @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, - 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 - interceptors=self.interceptors, # Pass interceptors to Worker - ) - - 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: - 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 deleted file mode 100644 index 3e4498162..000000000 --- a/src/agentex/lib/core/temporal/workflows/workflow.py +++ /dev/null @@ -1,26 +0,0 @@ -from abc import ABC, abstractmethod - -from temporalio import workflow - -from agentex.protocol.acp import SendEventParams, CreateTaskParams -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 diff --git a/src/agentex/lib/core/tracing/__init__.py b/src/agentex/lib/core/tracing/__init__.py deleted file mode 100644 index 639f3ba8e..000000000 --- a/src/agentex/lib/core/tracing/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -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_queue import ( - AsyncSpanQueue, - get_default_span_queue, - shutdown_default_span_queue, -) - -__all__ = [ - "Trace", - "AsyncTrace", - "Span", - "Tracer", - "AsyncTracer", - "AsyncSpanQueue", - "get_default_span_queue", - "shutdown_default_span_queue", -] diff --git a/src/agentex/lib/core/tracing/processors/agentex_tracing_processor.py b/src/agentex/lib/core/tracing/processors/agentex_tracing_processor.py deleted file mode 100644 index 98d50546b..000000000 --- a/src/agentex/lib/core/tracing/processors/agentex_tracing_processor.py +++ /dev/null @@ -1,160 +0,0 @@ -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.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 - - -class AgentexSyncTracingProcessor(SyncTracingProcessor): - def __init__(self, config: AgentexTracingProcessorConfig): # noqa: ARG002 - self.client = Agentex() - - @override - def on_span_start(self, span: Span) -> None: - self.client.spans.create( - name=span.name, - start_time=span.start_time, - end_time=span.end_time, - trace_id=span.trace_id, - id=span.id, - data=span.data, - input=span.input, - output=span.output, - parent_id=span.parent_id, - task_id=span.task_id, - ) - - @override - def on_span_end(self, span: Span) -> None: - 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() - - 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: - await self.client.spans.create( - 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, - ) - - @override - async def on_span_end(self, span: Span) -> None: - 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 deleted file mode 100644 index ced4c5d2c..000000000 --- a/src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py +++ /dev/null @@ -1,188 +0,0 @@ -from __future__ import annotations - -import asyncio -import weakref -from typing import 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.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.processors.tracing_processor_interface import ( - SyncTracingProcessor, - AsyncTracingProcessor, -) - -logger = make_logger(__name__) - - -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 - - -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=span.data, - ), - ) - sgp_span.start_time = span.start_time.isoformat() # type: ignore[union-attr] - 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() - - @override - def on_span_start(self, span: Span) -> None: - 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() - - 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: - 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 deleted file mode 100644 index f352f38c4..000000000 --- a/src/agentex/lib/core/tracing/processors/tracing_processor_interface.py +++ /dev/null @@ -1,83 +0,0 @@ -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_queue.py b/src/agentex/lib/core/tracing/span_queue.py deleted file mode 100644 index 5d77e3440..000000000 --- a/src/agentex/lib/core/tracing/span_queue.py +++ /dev/null @@ -1,463 +0,0 @@ -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__) - -_DEFAULT_BATCH_SIZE = 50 -_DEFAULT_LINGER_MS = 100 -# 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 = _DEFAULT_BATCH_SIZE, - 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 = 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/trace.py b/src/agentex/lib/core/tracing/trace.py deleted file mode 100644 index 70b268b18..000000000 --- a/src/agentex/lib/core/tracing/trace.py +++ /dev/null @@ -1,325 +0,0 @@ -from __future__ import annotations - -import uuid -from typing import Any, AsyncGenerator -from datetime import UTC, datetime -from contextlib import contextmanager, asynccontextmanager - -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.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__) - - -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 - - 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 - id = str(uuid.uuid4()) - - 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, - ) - - for processor in self.processors: - processor.on_span_start(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) - - 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: - processor.on_span_end(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 - 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() - - 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 - id = str(uuid.uuid4()) - - 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 self.processors: - self._span_queue.enqueue(SpanEventType.START, span.model_copy(deep=True), self.processors) - - 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) - - 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: - self._span_queue.enqueue(SpanEventType.END, span.model_copy(deep=True), self.processors) - - 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 - finally: - await self.end_span(span) diff --git a/src/agentex/lib/core/tracing/tracer.py b/src/agentex/lib/core/tracing/tracer.py deleted file mode 100644 index 3af79977e..000000000 --- a/src/agentex/lib/core/tracing/tracer.py +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index 07c440313..000000000 --- a/src/agentex/lib/core/tracing/tracing_processor_manager.py +++ /dev/null @@ -1,80 +0,0 @@ -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 deleted file mode 100644 index 31ce43ab8..000000000 --- a/src/agentex/lib/environment_variables.py +++ /dev/null @@ -1,126 +0,0 @@ - -from __future__ import annotations - -import os -from enum import Enum -from pathlib import Path - -from dotenv import load_dotenv -from pydantic import Field - -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_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" - # Build Information - BUILD_INFO_PATH = "BUILD_INFO_PATH" - 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 - 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 time (in seconds) a workflow execution can run, including - # retries and continue-as-new. Defaults to 24h to bound runaway workflows; - # agents with longer-running tasks should override this. Must be > 0 โ€” a - # zero or negative timedelta would cause every submitted workflow to fail. - WORKFLOW_EXECUTION_TIMEOUT_SECONDS: int = Field(default=86400, gt=0) - # Temporal Worker Configuration - HEALTH_CHECK_PORT: int = 80 - # Auth Configuration - AUTH_PRINCIPAL_B64: str | None = None - # Build Information - BUILD_INFO_PATH: 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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/sdk/__init__.py b/src/agentex/lib/sdk/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/sdk/config/__init__.py b/src/agentex/lib/sdk/config/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/sdk/config/agent_config.py b/src/agentex/lib/sdk/config/agent_config.py deleted file mode 100644 index c5f319944..000000000 --- a/src/agentex/lib/sdk/config/agent_config.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -from typing import Any, Literal - -from pydantic import Field - -from agentex.lib.utils.logging import make_logger -from agentex.lib.types.credentials import CredentialMapping -from agentex.lib.utils.model_utils import BaseModel -from agentex.lib.types.agent_configs import TemporalConfig, TemporalWorkflowConfig - -logger = make_logger(__name__) - - -class AgentConfig(BaseModel): - 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/lib/sdk/config/agent_manifest.py b/src/agentex/lib/sdk/config/agent_manifest.py deleted file mode 100644 index 19378ed52..000000000 --- a/src/agentex/lib/sdk/config/agent_manifest.py +++ /dev/null @@ -1,243 +0,0 @@ -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 pydantic import Field - -from agentex.lib.utils.logging import make_logger -from agentex.lib.utils.model_utils import BaseModel -from agentex.lib.sdk.config.agent_config import AgentConfig -from agentex.lib.sdk.config.build_config import BuildConfig -from agentex.lib.sdk.config.deployment_config import DeploymentConfig -from agentex.lib.sdk.config.environment_config import AgentEnvironmentsConfig -from agentex.lib.sdk.config.local_development_config import LocalDevelopmentConfig - -logger = make_logger(__name__) - - -class AgentManifest(BaseModel): - """ - 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" - ) - - - def context_manager(self, build_context_root: Path) -> BuildContextManager: - """ - Creates a build context manager - """ - return BuildContextManager( - agent_manifest=self, build_context_root=build_context_root - ) - - def load_environments_config(self, manifest_dir: Path) -> "AgentEnvironmentsConfig | None": - """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 - """ - # Import here to avoid circular imports - from agentex.lib.sdk.config.environment_config import load_environments_config_from_manifest_dir - - return load_environments_config_from_manifest_dir(manifest_dir) - - -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() - - with tarfile.open(fileobj=tar_buffer, mode="w:gz") as tar_file: - for path in Path(root_path).rglob( - "*" - ): # Recursively add files to the tar.gz - if path.is_file(): # Ensure that we're only adding files - 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 deleted file mode 100644 index 96a7f92e5..000000000 --- a/src/agentex/lib/sdk/config/build_config.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -from pydantic import Field - -from agentex.lib.utils.model_utils import BaseModel - - -class BuildContext(BaseModel): - """ - 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(BaseModel): - """ - Represents a configuration for building the action as a Docker image. - """ - - context: BuildContext diff --git a/src/agentex/lib/sdk/config/deployment_config.py b/src/agentex/lib/sdk/config/deployment_config.py deleted file mode 100644 index 1ba5b348f..000000000 --- a/src/agentex/lib/sdk/config/deployment_config.py +++ /dev/null @@ -1,123 +0,0 @@ -from __future__ import annotations - -from typing import Any, Dict - -from pydantic import Field - -from agentex.lib.utils.model_utils import BaseModel - - -class ImageConfig(BaseModel): - """Configuration for container images""" - - repository: str = Field(..., description="Container image repository URL") - tag: str = Field(default="latest", description="Container image tag") - - -class ImagePullSecretConfig(BaseModel): - """Configuration for image pull secrets""" - - name: str = Field(..., description="Name of the image pull secret") - - -class ResourceRequirements(BaseModel): - """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(BaseModel): - """Resource configuration for containers""" - - requests: ResourceRequirements = Field( - default_factory=ResourceRequirements, description="Resource requests" - ) - limits: ResourceRequirements = Field( - default_factory=ResourceRequirements, description="Resource limits" - ) - - -class GlobalDeploymentConfig(BaseModel): - """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(BaseModel): - """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 Config: - validate_by_name = True - - -class ClusterConfig(BaseModel): - """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(BaseModel): - principal: Dict[str, Any] = Field(description="Principal used for authorization on registration") - - -class InjectedImagePullSecretValues(BaseModel): - """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(BaseModel): - """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", - ) - - class Config: - validate_by_name = True diff --git a/src/agentex/lib/sdk/config/environment_config.py b/src/agentex/lib/sdk/config/environment_config.py deleted file mode 100644 index cad86419a..000000000 --- a/src/agentex/lib/sdk/config/environment_config.py +++ /dev/null @@ -1,269 +0,0 @@ -""" -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. -""" - -from __future__ import annotations - -from typing import Any, Dict, Literal, override -from pathlib import Path - -import yaml -from pydantic import Field, BaseModel, field_validator - -from agentex.lib.utils.model_utils import BaseModel as UtilsBaseModel - - -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(UtilsBaseModel): - """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()) - - @classmethod - @override - def from_yaml(cls, file_path: str) -> "AgentEnvironmentsConfig": - """Load configuration from environments.yaml file. - - 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 cls.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 AgentEnvironmentsConfig.from_yaml(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 deleted file mode 100644 index 061500ab7..000000000 --- a/src/agentex/lib/sdk/config/local_development_config.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from pydantic import Field, validator - -from agentex.lib.utils.model_utils import BaseModel - - -class LocalAgentConfig(BaseModel): - """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(BaseModel): - """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)", - ) - - @validator("acp", "worker") - 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(BaseModel): - """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/sdk/config/project_config.py b/src/agentex/lib/sdk/config/project_config.py deleted file mode 100644 index 0621ae37a..000000000 --- a/src/agentex/lib/sdk/config/project_config.py +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index f4853de7f..000000000 --- a/src/agentex/lib/sdk/config/validation.py +++ /dev/null @@ -1,255 +0,0 @@ -""" -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.lib.sdk.config.environment_config import AgentEnvironmentConfig, AgentEnvironmentsConfig - -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 = AgentEnvironmentsConfig.from_yaml(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 deleted file mode 100644 index b69863798..000000000 --- a/src/agentex/lib/sdk/fastacp/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index b0b1c3685..000000000 --- a/src/agentex/lib/sdk/fastacp/base/base_acp_server.py +++ /dev/null @@ -1,417 +0,0 @@ -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, -) -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.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) - - -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: - if scope["type"] == "http": - headers = dict(scope.get("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) - await self.app(scope, receive, send) - - -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/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: - 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 - - 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: 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 deleted file mode 100644 index c04287e0c..000000000 --- a/src/agentex/lib/sdk/fastacp/base/constants.py +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 42859793d..000000000 --- a/src/agentex/lib/sdk/fastacp/fastacp.py +++ /dev/null @@ -1,125 +0,0 @@ -from __future__ import annotations - -import os -import inspect -from typing import Any, Literal -from pathlib import Path -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 locate_build_info_path() -> None: - """If a build-info.json file is present, set the BUILD_INFO_PATH environment variable""" - acp_root = Path(inspect.stack()[2].filename).resolve().parents[0] - build_info_path = acp_root / "build-info.json" - if build_info_path.exists(): - os.environ["BUILD_INFO_PATH"] = str(build_info_path) - - @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 - """ - - FastACP.locate_build_info_path() - - 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 deleted file mode 100644 index e9d20f150..000000000 --- a/src/agentex/lib/sdk/fastacp/impl/async_base_acp.py +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index 5ecad073e..000000000 --- a/src/agentex/lib/sdk/fastacp/impl/sync_acp.py +++ /dev/null @@ -1,111 +0,0 @@ -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 deleted file mode 100644 index 69d843720..000000000 --- a/src/agentex/lib/sdk/fastacp/impl/temporal_acp.py +++ /dev/null @@ -1,134 +0,0 @@ -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, -) -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 diff --git a/src/agentex/lib/sdk/fastacp/tests/README.md b/src/agentex/lib/sdk/fastacp/tests/README.md deleted file mode 100644 index fa958012b..000000000 --- a/src/agentex/lib/sdk/fastacp/tests/README.md +++ /dev/null @@ -1,297 +0,0 @@ -# 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 deleted file mode 100644 index 59ecbfee3..000000000 --- a/src/agentex/lib/sdk/fastacp/tests/conftest.py +++ /dev/null @@ -1,311 +0,0 @@ -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 deleted file mode 100644 index c36f46f20..000000000 --- a/src/agentex/lib/sdk/fastacp/tests/pytest.ini +++ /dev/null @@ -1,10 +0,0 @@ -[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 deleted file mode 100644 index 8b23be165..000000000 --- a/src/agentex/lib/sdk/fastacp/tests/run_tests.py +++ /dev/null @@ -1,227 +0,0 @@ -#!/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 deleted file mode 100644 index 8a218187e..000000000 --- a/src/agentex/lib/sdk/fastacp/tests/test_base_acp_server.py +++ /dev/null @@ -1,450 +0,0 @@ -# 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 deleted file mode 100644 index 8c62efa03..000000000 --- a/src/agentex/lib/sdk/fastacp/tests/test_fastacp_factory.py +++ /dev/null @@ -1,371 +0,0 @@ -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 deleted file mode 100644 index c72d336e3..000000000 --- a/src/agentex/lib/sdk/fastacp/tests/test_integration.py +++ /dev/null @@ -1,478 +0,0 @@ -# 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 deleted file mode 100644 index 6013d28f6..000000000 --- a/src/agentex/lib/sdk/state_machine/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index a7c54cfb9..000000000 --- a/src/agentex/lib/sdk/state_machine/noop_workflow.py +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 6ddddc0c0..000000000 --- a/src/agentex/lib/sdk/state_machine/state.py +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index f1e5c4239..000000000 --- a/src/agentex/lib/sdk/state_machine/state_machine.py +++ /dev/null @@ -1,220 +0,0 @@ -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. - """ - 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: - 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 deleted file mode 100644 index dc5f5ff83..000000000 --- a/src/agentex/lib/sdk/state_machine/state_workflow.py +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/sdk/utils/messages.py b/src/agentex/lib/sdk/utils/messages.py deleted file mode 100644 index bddd81050..000000000 --- a/src/agentex/lib/sdk/utils/messages.py +++ /dev/null @@ -1,225 +0,0 @@ -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/types/__init__.py b/src/agentex/lib/types/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/types/acp.py b/src/agentex/lib/types/acp.py deleted file mode 100644 index 74295ef88..000000000 --- a/src/agentex/lib/types/acp.py +++ /dev/null @@ -1,15 +0,0 @@ -"""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, -) diff --git a/src/agentex/lib/types/agent_card.py b/src/agentex/lib/types/agent_card.py deleted file mode 100644 index def4464c6..000000000 --- a/src/agentex/lib/types/agent_card.py +++ /dev/null @@ -1,149 +0,0 @@ -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 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 - - @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, - ) -> 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, - ) - - @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, - ) -> 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, - ) - - -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 deleted file mode 100644 index 7a3a0f091..000000000 --- a/src/agentex/lib/types/agent_configs.py +++ /dev/null @@ -1,86 +0,0 @@ -from __future__ import annotations - -from pydantic import Field, BaseModel, 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.", - ) - - @validator("workflows") - 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/lib/types/agent_results.py b/src/agentex/lib/types/agent_results.py deleted file mode 100644 index 909593c18..000000000 --- a/src/agentex/lib/types/agent_results.py +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 1e3676b55..000000000 --- a/src/agentex/lib/types/converters.py +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index 7f4b79d1c..000000000 --- a/src/agentex/lib/types/credentials.py +++ /dev/null @@ -1,34 +0,0 @@ -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/lib/types/fastacp.py b/src/agentex/lib/types/fastacp.py deleted file mode 100644 index 493ca5f11..000000000 --- a/src/agentex/lib/types/fastacp.py +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index ddf104dd2..000000000 --- a/src/agentex/lib/types/files.py +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index b010f93f7..000000000 --- a/src/agentex/lib/types/json_rpc.py +++ /dev/null @@ -1,11 +0,0 @@ -"""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 deleted file mode 100644 index 04192c003..000000000 --- a/src/agentex/lib/types/llm_messages.py +++ /dev/null @@ -1,357 +0,0 @@ -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 deleted file mode 100644 index 721d87794..000000000 --- a/src/agentex/lib/types/tracing.py +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/utils/completions.py b/src/agentex/lib/utils/completions.py deleted file mode 100644 index fe62c7d12..000000000 --- a/src/agentex/lib/utils/completions.py +++ /dev/null @@ -1,147 +0,0 @@ -from __future__ import annotations - -from copy import deepcopy -from typing import Any -from functools import reduce, singledispatch - -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: - a.choices = [_concat_chunks(*c) for c in zip(a.choices, b.choices, strict=False)] - 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 deleted file mode 100644 index eab21efa8..000000000 --- a/src/agentex/lib/utils/console.py +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 831199f9a..000000000 --- a/src/agentex/lib/utils/debug.py +++ /dev/null @@ -1,73 +0,0 @@ -""" -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 deleted file mode 100644 index 38d7726a5..000000000 --- a/src/agentex/lib/utils/dev_tools/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""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 deleted file mode 100644 index 7c6275329..000000000 --- a/src/agentex/lib/utils/dev_tools/async_messages.py +++ /dev/null @@ -1,423 +0,0 @@ -""" -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 deleted file mode 100644 index f8dfcc463..000000000 --- a/src/agentex/lib/utils/io.py +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 7119ddb6a..000000000 --- a/src/agentex/lib/utils/iterables.py +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 6c8fa5c37..000000000 --- a/src/agentex/lib/utils/json_schema.py +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 5bbaf61ac..000000000 --- a/src/agentex/lib/utils/logging.py +++ /dev/null @@ -1,79 +0,0 @@ -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") - - -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(logging.INFO) - - 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 deleted file mode 100644 index bebe9364b..000000000 --- a/src/agentex/lib/utils/mcp.py +++ /dev/null @@ -1,20 +0,0 @@ -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/model_utils.py b/src/agentex/lib/utils/model_utils.py deleted file mode 100644 index 8826ba121..000000000 --- a/src/agentex/lib/utils/model_utils.py +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index ecb61206a..000000000 --- a/src/agentex/lib/utils/parsing.py +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index c760b10dd..000000000 --- a/src/agentex/lib/utils/regex.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index be9db981a..000000000 --- a/src/agentex/protocol/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""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 deleted file mode 100644 index d719b4fd5..000000000 --- a/src/agentex/protocol/acp.py +++ /dev/null @@ -1,116 +0,0 @@ -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" - - -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", - ) - - -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, -} diff --git a/src/agentex/protocol/json_rpc.py b/src/agentex/protocol/json_rpc.py deleted file mode 100644 index be03a4936..000000000 --- a/src/agentex/protocol/json_rpc.py +++ /dev/null @@ -1,63 +0,0 @@ -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/resources/agents/agents.py b/src/agentex/resources/agents/agents.py index d3442e01c..205eea675 100644 --- a/src/agentex/resources/agents/agents.py +++ b/src/agentex/resources/agents/agents.py @@ -2,15 +2,13 @@ from __future__ import annotations -import json -from typing import Any, Dict, Union, Optional, Generator, AsyncGenerator +from typing import Dict, Union, Optional 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 ..._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 .schedules import ( @@ -38,14 +36,7 @@ ) 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_rpc_response import AgentRpcResponse from ...types.agent_list_response import AgentListResponse from ...types.shared.delete_response import DeleteResponse @@ -421,300 +412,6 @@ def rpc_by_name( ), 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): @@ -1086,302 +783,7 @@ async def rpc_by_name( ), 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: diff --git a/src/agentex/resources/states.py b/src/agentex/resources/states.py index d8cfa1ad8..52fecbd0c 100644 --- a/src/agentex/resources/states.py +++ b/src/agentex/resources/states.py @@ -122,9 +122,7 @@ def update( self, state_id: str, *, - 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, @@ -148,14 +146,7 @@ def update( 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( - { - "agent_id": agent_id, - "state": state, - "task_id": task_id, - }, - state_update_params.StateUpdateParams, - ), + 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 ), @@ -356,9 +347,7 @@ async def update( self, state_id: str, *, - 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, @@ -382,14 +371,7 @@ async def update( 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( - { - "agent_id": agent_id, - "state": state, - "task_id": task_id, - }, - state_update_params.StateUpdateParams, - ), + 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 ), diff --git a/src/agentex/types/agent_rpc_by_name_params.py b/src/agentex/types/agent_rpc_by_name_params.py index 65bcc65c5..5189926fe 100644 --- a/src/agentex/types/agent_rpc_by_name_params.py +++ b/src/agentex/types/agent_rpc_by_name_params.py @@ -30,10 +30,20 @@ class AgentRpcByNameParams(TypedDict, total=False): class ParamsCreateTaskRequest(TypedDict, total=False): name: Optional[str] - """The name of the task to create""" + """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""" + """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. diff --git a/src/agentex/types/agent_rpc_params.py b/src/agentex/types/agent_rpc_params.py index 9129dc166..bf13912fd 100644 --- a/src/agentex/types/agent_rpc_params.py +++ b/src/agentex/types/agent_rpc_params.py @@ -30,10 +30,20 @@ class AgentRpcParams(TypedDict, total=False): class ParamsCreateTaskRequest(TypedDict, total=False): name: Optional[str] - """The name of the task to create""" + """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""" + """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. diff --git a/src/agentex/types/agent_rpc_response.py b/src/agentex/types/agent_rpc_response.py index 97f0f9c2f..e9995e801 100644 --- a/src/agentex/types/agent_rpc_response.py +++ b/src/agentex/types/agent_rpc_response.py @@ -1,56 +1,20 @@ # 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", -] +__all__ = ["AgentRpcResponse"] -class BaseAgentRpcResponse(BaseModel): - id: Union[int, str, None] = None - error: Optional[object] = None - jsonrpc: Optional[Literal["2.0"]] = None - - -class AgentRpcResponse(BaseAgentRpcResponse): +class AgentRpcResponse(BaseModel): result: Optional[AgentRpcResult] = None """The result of the agent RPC request""" + id: Union[int, str, None] = None -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""" - + error: Optional[object] = None -class SendEventResponse(BaseAgentRpcResponse): - result: Event - """The result of the event sending""" + jsonrpc: Optional[Literal["2.0"]] = None diff --git a/src/agentex/types/agents/deployment_preview_rpc_params.py b/src/agentex/types/agents/deployment_preview_rpc_params.py index 61112956a..ba8e20c71 100644 --- a/src/agentex/types/agents/deployment_preview_rpc_params.py +++ b/src/agentex/types/agents/deployment_preview_rpc_params.py @@ -32,10 +32,20 @@ class DeploymentPreviewRpcParams(TypedDict, total=False): class ParamsCreateTaskRequest(TypedDict, total=False): name: Optional[str] - """The name of the task to create""" + """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""" + """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. diff --git a/src/agentex/types/data_content.py b/src/agentex/types/data_content.py index f23212fe7..2ed340454 100644 --- a/src/agentex/types/data_content.py +++ b/src/agentex/types/data_content.py @@ -1,6 +1,6 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import Dict +from typing import Dict, Optional from typing_extensions import Literal from .._models import BaseModel @@ -20,11 +20,11 @@ class DataContent(BaseModel): data: Dict[str, object] """The contents of the data message.""" - style: MessageStyle = "static" + style: Optional[MessageStyle] = None """The style of the message. This is used by the client to determine how to display the message. """ - type: Literal["data"] = "data" + type: Optional[Literal["data"]] = None """The type of the message, in this case `data`.""" diff --git a/src/agentex/types/state_update_params.py b/src/agentex/types/state_update_params.py index 286914624..4a4d2d744 100644 --- a/src/agentex/types/state_update_params.py +++ b/src/agentex/types/state_update_params.py @@ -9,8 +9,4 @@ class StateUpdateParams(TypedDict, total=False): - agent_id: Required[str] - state: Required[Dict[str, object]] - - task_id: Required[str] diff --git a/src/agentex/types/text_content.py b/src/agentex/types/text_content.py index 8c8b77e8a..35fefb06d 100644 --- a/src/agentex/types/text_content.py +++ b/src/agentex/types/text_content.py @@ -40,17 +40,17 @@ class TextContent(BaseModel): attachments: Optional[List[Attachment]] = None """Optional list of file attachments with structured metadata.""" - format: TextFormat = "plain" + format: Optional[TextFormat] = None """The format of the message. This is used by the client to determine how to display the message. """ - style: MessageStyle = "static" + style: Optional[MessageStyle] = None """The style of the message. This is used by the client to determine how to display the message. """ - type: Literal["text"] = "text" + type: Optional[Literal["text"]] = None """The type of the message, in this case `text`.""" diff --git a/src/agentex/types/tool_request_content.py b/src/agentex/types/tool_request_content.py index 8282ac3b7..66128630a 100644 --- a/src/agentex/types/tool_request_content.py +++ b/src/agentex/types/tool_request_content.py @@ -1,6 +1,6 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import Dict +from typing import Dict, Optional from typing_extensions import Literal from .._models import BaseModel @@ -26,11 +26,11 @@ class ToolRequestContent(BaseModel): tool_call_id: str """The ID of the tool call that is being requested.""" - style: MessageStyle = "static" + style: Optional[MessageStyle] = None """The style of the message. This is used by the client to determine how to display the message. """ - type: Literal["tool_request"] = "tool_request" + type: Optional[Literal["tool_request"]] = None """The type of the message, in this case `tool_request`.""" diff --git a/src/agentex/types/tool_response_content.py b/src/agentex/types/tool_response_content.py index bf1559746..f6ba15b72 100644 --- a/src/agentex/types/tool_response_content.py +++ b/src/agentex/types/tool_response_content.py @@ -1,5 +1,6 @@ # 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 @@ -25,11 +26,11 @@ class ToolResponseContent(BaseModel): tool_call_id: str """The ID of the tool call that is being responded to.""" - style: MessageStyle = "static" + style: Optional[MessageStyle] = None """The style of the message. This is used by the client to determine how to display the message. """ - type: Literal["tool_response"] = "tool_response" + type: Optional[Literal["tool_response"]] = None """The type of the message, in this case `tool_response`.""" diff --git a/src/agentex_sdk/lib/.keep b/src/agentex_sdk/lib/.keep new file mode 100644 index 000000000..5e2c99fdb --- /dev/null +++ b/src/agentex_sdk/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/tests/api_resources/agents/test_deployments.py b/tests/api_resources/agents/test_deployments.py index 7bddf7c49..3a429c0f1 100644 --- a/tests/api_resources/agents/test_deployments.py +++ b/tests/api_resources/agents/test_deployments.py @@ -8,6 +8,7 @@ import pytest from agentex import Agentex, AsyncAgentex +from tests.utils import assert_matches_type from agentex.types import AgentRpcResponse from agentex.types.agents import ( DeploymentListResponse, @@ -17,8 +18,6 @@ ) 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") diff --git a/tests/api_resources/agents/test_schedules.py b/tests/api_resources/agents/test_schedules.py index 0431de18e..46ef24aa1 100644 --- a/tests/api_resources/agents/test_schedules.py +++ b/tests/api_resources/agents/test_schedules.py @@ -8,6 +8,7 @@ import pytest from agentex import Agentex, AsyncAgentex +from tests.utils import assert_matches_type from agentex._utils import parse_datetime from agentex.types.agents import ( ScheduleListResponse, @@ -19,8 +20,6 @@ ) 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") diff --git a/tests/api_resources/messages/test_batch.py b/tests/api_resources/messages/test_batch.py index e2fd9cd44..18e438610 100644 --- a/tests/api_resources/messages/test_batch.py +++ b/tests/api_resources/messages/test_batch.py @@ -8,11 +8,10 @@ import pytest from agentex import Agentex, AsyncAgentex +from tests.utils import assert_matches_type 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") diff --git a/tests/api_resources/test_agents.py b/tests/api_resources/test_agents.py index 472317864..1fd5fdd23 100644 --- a/tests/api_resources/test_agents.py +++ b/tests/api_resources/test_agents.py @@ -8,6 +8,7 @@ import pytest from agentex import Agentex, AsyncAgentex +from tests.utils import assert_matches_type from agentex.types import ( Agent, AgentRpcResponse, @@ -15,8 +16,6 @@ ) 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") diff --git a/tests/api_resources/test_checkpoints.py b/tests/api_resources/test_checkpoints.py index 3c13fd43a..68d297cab 100644 --- a/tests/api_resources/test_checkpoints.py +++ b/tests/api_resources/test_checkpoints.py @@ -8,14 +8,13 @@ import pytest from agentex import Agentex, AsyncAgentex +from tests.utils import assert_matches_type 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") diff --git a/tests/api_resources/test_deployment_history.py b/tests/api_resources/test_deployment_history.py index 1b98042b7..0abc87c67 100644 --- a/tests/api_resources/test_deployment_history.py +++ b/tests/api_resources/test_deployment_history.py @@ -8,10 +8,9 @@ import pytest from agentex import Agentex, AsyncAgentex +from tests.utils import assert_matches_type 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") diff --git a/tests/api_resources/test_events.py b/tests/api_resources/test_events.py index 7a0805b52..414c93d66 100644 --- a/tests/api_resources/test_events.py +++ b/tests/api_resources/test_events.py @@ -8,10 +8,9 @@ import pytest from agentex import Agentex, AsyncAgentex +from tests.utils import assert_matches_type 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") diff --git a/tests/api_resources/test_messages.py b/tests/api_resources/test_messages.py index f93506eba..9f9c646c4 100644 --- a/tests/api_resources/test_messages.py +++ b/tests/api_resources/test_messages.py @@ -8,6 +8,7 @@ import pytest from agentex import Agentex, AsyncAgentex +from tests.utils import assert_matches_type from agentex.types import ( TaskMessage, MessageListResponse, @@ -15,8 +16,6 @@ ) 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") diff --git a/tests/api_resources/test_spans.py b/tests/api_resources/test_spans.py index 7cccec2ad..be2f43ac7 100644 --- a/tests/api_resources/test_spans.py +++ b/tests/api_resources/test_spans.py @@ -8,11 +8,10 @@ import pytest from agentex import Agentex, AsyncAgentex +from tests.utils import assert_matches_type 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") diff --git a/tests/api_resources/test_states.py b/tests/api_resources/test_states.py index 2e5e91a0c..dcd7c563b 100644 --- a/tests/api_resources/test_states.py +++ b/tests/api_resources/test_states.py @@ -8,10 +8,9 @@ import pytest from agentex import Agentex, AsyncAgentex +from tests.utils import assert_matches_type 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") @@ -105,9 +104,7 @@ def test_path_params_retrieve(self, client: Agentex) -> None: def test_method_update(self, client: Agentex) -> None: state = client.states.update( state_id="state_id", - agent_id="agent_id", state={"foo": "bar"}, - task_id="task_id", ) assert_matches_type(State, state, path=["response"]) @@ -116,9 +113,7 @@ def test_method_update(self, client: Agentex) -> None: def test_raw_response_update(self, client: Agentex) -> None: response = client.states.with_raw_response.update( state_id="state_id", - agent_id="agent_id", state={"foo": "bar"}, - task_id="task_id", ) assert response.is_closed is True @@ -131,9 +126,7 @@ def test_raw_response_update(self, client: Agentex) -> None: def test_streaming_response_update(self, client: Agentex) -> None: with client.states.with_streaming_response.update( state_id="state_id", - 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" @@ -149,9 +142,7 @@ 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="", - agent_id="agent_id", state={"foo": "bar"}, - task_id="task_id", ) @pytest.mark.skip(reason="Mock server tests are disabled") @@ -330,9 +321,7 @@ async def test_path_params_retrieve(self, async_client: AsyncAgentex) -> None: async def test_method_update(self, async_client: AsyncAgentex) -> None: state = await async_client.states.update( state_id="state_id", - agent_id="agent_id", state={"foo": "bar"}, - task_id="task_id", ) assert_matches_type(State, state, path=["response"]) @@ -341,9 +330,7 @@ async def test_method_update(self, async_client: AsyncAgentex) -> None: async def test_raw_response_update(self, async_client: AsyncAgentex) -> None: response = await async_client.states.with_raw_response.update( state_id="state_id", - agent_id="agent_id", state={"foo": "bar"}, - task_id="task_id", ) assert response.is_closed is True @@ -356,9 +343,7 @@ async def test_raw_response_update(self, async_client: AsyncAgentex) -> None: async def test_streaming_response_update(self, async_client: AsyncAgentex) -> None: async with async_client.states.with_streaming_response.update( state_id="state_id", - 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" @@ -374,9 +359,7 @@ 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="", - agent_id="agent_id", state={"foo": "bar"}, - task_id="task_id", ) @pytest.mark.skip(reason="Mock server tests are disabled") diff --git a/tests/api_resources/test_tasks.py b/tests/api_resources/test_tasks.py index 0e70529dd..c665c18c8 100644 --- a/tests/api_resources/test_tasks.py +++ b/tests/api_resources/test_tasks.py @@ -8,6 +8,7 @@ import pytest from agentex import Agentex, AsyncAgentex +from tests.utils import assert_matches_type from agentex.types import ( Task, TaskListResponse, @@ -17,8 +18,6 @@ ) 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") diff --git a/tests/api_resources/test_tracker.py b/tests/api_resources/test_tracker.py index d56f4b6db..b8bf25c04 100644 --- a/tests/api_resources/test_tracker.py +++ b/tests/api_resources/test_tracker.py @@ -8,10 +8,9 @@ import pytest from agentex import Agentex, AsyncAgentex +from tests.utils import assert_matches_type 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") diff --git a/tests/lib/__init__.py b/tests/lib/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/lib/adk/__init__.py b/tests/lib/adk/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/lib/adk/conftest.py b/tests/lib/adk/conftest.py deleted file mode 100644 index 6d17956a8..000000000 --- a/tests/lib/adk/conftest.py +++ /dev/null @@ -1,33 +0,0 @@ -"""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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/lib/adk/providers/test_openai_activities.py b/tests/lib/adk/providers/test_openai_activities.py deleted file mode 100644 index c933b6ce4..000000000 --- a/tests/lib/adk/providers/test_openai_activities.py +++ /dev/null @@ -1,705 +0,0 @@ -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) - - # 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" - - 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}") - - 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() - - # 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): - yield mock_streaming_context - - mock_streaming_service.streaming_task_message_context = mock_streaming_context_manager - - openai_service.streaming_service = mock_streaming_service - openai_service.agentex_client = mock_agentex_client - - return mock_streaming_context - - 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 = [] - 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/test_messages_module.py b/tests/lib/adk/test_messages_module.py deleted file mode 100644 index 78ef0b424..000000000 --- a/tests/lib/adk/test_messages_module.py +++ /dev/null @@ -1,120 +0,0 @@ -"""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 deleted file mode 100644 index 9b18324dc..000000000 --- a/tests/lib/adk/test_messages_service.py +++ /dev/null @@ -1,98 +0,0 @@ -"""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_pydantic_ai_async.py b/tests/lib/adk/test_pydantic_ai_async.py deleted file mode 100644 index dadda5914..000000000 --- a/tests/lib/adk/test_pydantic_ai_async.py +++ /dev/null @@ -1,869 +0,0 @@ -"""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_async 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) -> 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_emits_full_tool_request_message_on_part_end( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - """Async helper uses Option A: tool requests are full messages, not delta streams.""" - streaming, messages = fake_adk - events = [ - 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='{"city":"Paris"}', tool_call_id="c1"), - ), - ] - await stream_pydantic_ai_events(_aiter(events), TASK_ID) - - assert streaming.contexts == [], "Tool calls do not open a streaming context" - assert len(messages.created) == 1 - msg = messages.created[0] - assert msg["task_id"] == TASK_ID - content = msg["content"] - assert isinstance(content, ToolRequestContent) - assert content.tool_call_id == "c1" - assert content.name == "get_weather" - assert content.arguments == {"city": "Paris"} - assert content.author == "agent" - - async def test_tool_call_with_dict_args_passes_through( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - _, 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) - - assert len(messages.created) == 1 - assert messages.created[0]["content"].arguments == {"q": "weather"} - - async def test_tool_call_with_invalid_json_args_surfaces_raw( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - """Don't drop the tool call when the model emits malformed JSON args. - - The arguments field is preserved under ``_raw`` so the failure is - visible to the UI rather than silently truncated. - """ - _, 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="not-json{", tool_call_id="c"), - ), - ] - await stream_pydantic_ai_events(_aiter(events), TASK_ID) - - assert len(messages.created) == 1 - assert messages.created[0]["content"].arguments == {"_raw": "not-json{"} - - async def test_tool_call_with_none_args_defaults_to_empty_dict( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - _, 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) - - assert len(messages.created) == 1 - assert messages.created[0]["content"].arguments == {} - - -class TestToolResult: - async def test_tool_return_emits_full_tool_response_message( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - _, 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 len(messages.created) == 1 - content = messages.created[0]["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" - - 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. - """ - _, 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) - - out = messages.created[0]["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 - - _, 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) - - out = messages.created[0]["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: - _, 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) - - assert len(messages.created) == 1 - content = messages.created[0]["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. - - Each text/reasoning segment must get its own streaming context that is - closed before the next one opens, and tool messages must interleave - correctly via ``adk.messages.create``. - """ - 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) - - assert len(streaming.contexts) == 2, "One context per text part โ€” tool calls don't open 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."] - - # Two messages: tool request, then tool response โ€” in that order. - assert [type(m["content"]).__name__ for m in messages.created] == [ - "ToolRequestContent", - "ToolResponseContent", - ] - assert messages.created[0]["content"].tool_call_id == "c1" - assert messages.created[1]["content"].tool_call_id == "c1" - 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.""" - 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" - ) - assert len(messages.created) == 1 - - -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 TestTracingHandler: - """Tracing handler hooks fire alongside streaming for each tool call.""" - - @dataclass - class _RecordingHandler: - starts: list[dict[str, Any]] = field(default_factory=list) - ends: list[dict[str, Any]] = field(default_factory=list) - - async def on_tool_start(self, tool_call_id: str, tool_name: str, arguments: Any) -> None: - self.starts.append({"tool_call_id": tool_call_id, "tool_name": tool_name, "arguments": arguments}) - - async def on_tool_end(self, tool_call_id: str, result: Any) -> None: - self.ends.append({"tool_call_id": tool_call_id, "result": result}) - - async def test_handler_records_start_and_end_for_each_tool_call( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - _, messages = fake_adk - handler = self._RecordingHandler() - events = [ - PartStartEvent( - index=0, - part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="c1"), - ), - PartEndEvent( - index=0, - part=ToolCallPart(tool_name="get_weather", args='{"city":"Paris"}', tool_call_id="c1"), - ), - FunctionToolResultEvent( - part=ToolReturnPart(tool_name="get_weather", content="Sunny", tool_call_id="c1"), - ), - ] - await stream_pydantic_ai_events( - _aiter(events), - TASK_ID, - tracing_handler=handler, # type: ignore[arg-type] - ) - - # Streaming side-effects still happen โ€” tracing is additive. - assert [type(m["content"]).__name__ for m in messages.created] == [ - "ToolRequestContent", - "ToolResponseContent", - ] - # And both lifecycle hooks fired exactly once with the right payload. - assert handler.starts == [ - { - "tool_call_id": "c1", - "tool_name": "get_weather", - "arguments": {"city": "Paris"}, - } - ] - assert handler.ends == [{"tool_call_id": "c1", "result": "Sunny"}] - - async def test_handler_not_called_when_no_tool_calls_in_stream( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - handler = self._RecordingHandler() - events = [ - PartStartEvent(index=0, part=TextPart(content="")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="Hello")), - PartEndEvent(index=0, part=TextPart(content="Hello")), - ] - await stream_pydantic_ai_events( - _aiter(events), - TASK_ID, - tracing_handler=handler, # type: ignore[arg-type] - ) - assert handler.starts == [] - assert handler.ends == [] - - async def test_handler_records_each_tool_in_multi_tool_run( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - """A turn with two tool calls must produce two start/end pairs in order.""" - handler = self._RecordingHandler() - events = [ - PartStartEvent( - index=0, - part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="c1"), - ), - PartEndEvent( - index=0, - 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=ToolCallPart(tool_name="lookup_city", args=None, tool_call_id="c2"), - ), - PartEndEvent( - index=0, - part=ToolCallPart(tool_name="lookup_city", args="{}", tool_call_id="c2"), - ), - FunctionToolResultEvent( - part=ToolReturnPart(tool_name="lookup_city", content="Paris, FR", tool_call_id="c2"), - ), - ] - await stream_pydantic_ai_events( - _aiter(events), - TASK_ID, - tracing_handler=handler, # type: ignore[arg-type] - ) - - assert [s["tool_call_id"] for s in handler.starts] == ["c1", "c2"] - assert [e["tool_call_id"] for e in handler.ends] == ["c1", "c2"] - assert handler.starts[0]["tool_name"] == "get_weather" - assert handler.starts[1]["tool_name"] == "lookup_city" - - async def test_omitting_handler_is_a_no_op_for_existing_behavior( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - """Regression: passing no tracing handler preserves the pre-tracing behavior.""" - _, messages = fake_adk - events = [ - PartStartEvent( - index=0, - part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="c1"), - ), - PartEndEvent( - index=0, - part=ToolCallPart(tool_name="get_weather", args="{}", tool_call_id="c1"), - ), - FunctionToolResultEvent( - part=ToolReturnPart(tool_name="get_weather", content="Sunny", tool_call_id="c1"), - ), - ] - await stream_pydantic_ai_events(_aiter(events), TASK_ID) - # Exact same shape as before tracing existed. - assert [type(m["content"]).__name__ for m in messages.created] == [ - "ToolRequestContent", - "ToolResponseContent", - ] - - -class TestPydanticAITracingHandlerDeterministicIds: - """Regression coverage for ``AgentexPydanticAITracingHandler``. - - pydantic-ai's ``TemporalAgent`` splits a single agent run across several - Temporal activities. The event_stream_handler is invoked once per - activity, with a fresh handler instance each time. So ``on_tool_start`` - (during the model activity that issued the tool call) and ``on_tool_end`` - (during the next model activity, after the tool ran) end up in DIFFERENT - handler instances โ€” an in-memory dict can't pair them. - - The fix is deterministic span IDs derived from ``(trace_id, tool_call_id)``. - These tests lock that in. - """ - - class _RecordingClient: - """Stand-in for ``AsyncAgentex`` capturing spans.create / spans.update calls.""" - - def __init__(self) -> None: - self.creates: list[dict[str, Any]] = [] - self.updates: list[tuple[str, dict[str, Any]]] = [] - self.spans = self # so .spans.create / .spans.update resolve back here - - async def create(self, **kwargs: Any) -> Any: - self.creates.append(kwargs) - return None - - async def update(self, span_id: str, **kwargs: Any) -> Any: - self.updates.append((span_id, kwargs)) - return None - - async def test_same_tool_call_id_yields_same_span_id_across_handler_instances( - self, - ) -> None: - """The whole point of the design: two handler instances with the same - trace_id and tool_call_id resolve to the same span ID โ€” otherwise - ``on_tool_end`` patches a different (non-existent) record and the span - in the DB never gets ``end_time`` / ``output``.""" - from agentex.lib.adk._modules._pydantic_ai_tracing import ( - AgentexPydanticAITracingHandler, - ) - - client_a = self._RecordingClient() - client_b = self._RecordingClient() - - # Two independent handler instances โ€” simulates the cross-activity - # invocation pattern in TemporalAgent. - handler_a = AgentexPydanticAITracingHandler( - trace_id="trace-1", - parent_span_id="parent-1", - task_id="task-1", - client=client_a, # type: ignore[arg-type] - ) - handler_b = AgentexPydanticAITracingHandler( - trace_id="trace-1", - parent_span_id="parent-1", - task_id="task-1", - client=client_b, # type: ignore[arg-type] - ) - - await handler_a.on_tool_start(tool_call_id="call_abc", tool_name="get_weather", arguments={"city": "Paris"}) - await handler_b.on_tool_end(tool_call_id="call_abc", result="Sunny, 72F") - - assert len(client_a.creates) == 1 - assert len(client_b.updates) == 1 - - created_span_id = client_a.creates[0]["id"] - updated_span_id = client_b.updates[0][0] - assert created_span_id == updated_span_id, ( - "on_tool_start and on_tool_end must address the same span across handler " - "instances; mismatch means tool spans will be left open and the AgentEx UI " - "will hide their trace." - ) - - async def test_different_tool_call_ids_yield_different_span_ids(self) -> None: - from agentex.lib.adk._modules._pydantic_ai_tracing import ( - AgentexPydanticAITracingHandler, - ) - - client = self._RecordingClient() - handler = AgentexPydanticAITracingHandler( - trace_id="trace-1", - client=client, # type: ignore[arg-type] - ) - - await handler.on_tool_start("call_a", "get_weather", {"city": "Paris"}) - await handler.on_tool_start("call_b", "get_weather", {"city": "Tokyo"}) - - ids = {c["id"] for c in client.creates} - assert len(ids) == 2, "Distinct tool_call_ids must map to distinct span IDs" - - async def test_same_tool_call_id_in_different_traces_yields_different_span_ids( - self, - ) -> None: - """Span IDs are namespaced by trace_id so two unrelated runs with the - same provider-issued tool_call_id don't collide.""" - from agentex.lib.adk._modules._pydantic_ai_tracing import ( - AgentexPydanticAITracingHandler, - ) - - client = self._RecordingClient() - handler_t1 = AgentexPydanticAITracingHandler(trace_id="trace-1", client=client) # type: ignore[arg-type] - handler_t2 = AgentexPydanticAITracingHandler(trace_id="trace-2", client=client) # type: ignore[arg-type] - - await handler_t1.on_tool_start("call_abc", "t", None) - await handler_t2.on_tool_start("call_abc", "t", None) - - ids = {c["id"] for c in client.creates} - assert len(ids) == 2 - - async def test_on_tool_end_patches_only_end_time_and_output(self) -> None: - """Don't overwrite start_time, name, parent_id, etc. on close โ€” only patch - the fields we have new values for. Sending start_time again could clobber - what was set at create time.""" - from agentex.lib.adk._modules._pydantic_ai_tracing import ( - AgentexPydanticAITracingHandler, - ) - - client = self._RecordingClient() - handler = AgentexPydanticAITracingHandler(trace_id="trace-1", client=client) # type: ignore[arg-type] - - await handler.on_tool_end("call_abc", "Sunny") - - assert len(client.updates) == 1 - _, patch_kwargs = client.updates[0] - assert set(patch_kwargs.keys()) == {"end_time", "output"}, ( - f"Unexpected fields in tool span PATCH: {set(patch_kwargs.keys())}" - ) - assert patch_kwargs["output"] == {"result": "Sunny"} - - async def test_on_tool_error_patches_error_output(self) -> None: - from agentex.lib.adk._modules._pydantic_ai_tracing import ( - AgentexPydanticAITracingHandler, - ) - - client = self._RecordingClient() - handler = AgentexPydanticAITracingHandler(trace_id="trace-1", client=client) # type: ignore[arg-type] - - await handler.on_tool_error("call_abc", RuntimeError("boom")) - - assert len(client.updates) == 1 - _, patch_kwargs = client.updates[0] - assert "error" in patch_kwargs["output"] - assert "boom" in patch_kwargs["output"]["error"] - - -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 diff --git a/tests/lib/adk/test_pydantic_ai_sync.py b/tests/lib/adk/test_pydantic_ai_sync.py deleted file mode 100644 index 36d06200e..000000000 --- a/tests/lib/adk/test_pydantic_ai_sync.py +++ /dev/null @@ -1,483 +0,0 @@ -"""Tests for the Pydantic AI -> Agentex stream event converter.""" - -from __future__ import annotations - -import json -from typing import Any, AsyncIterator - -import pytest -from pydantic_ai.messages import ( - TextPart, - PartEndEvent, - ThinkingPart, - ToolCallPart, - TextPartDelta, - PartDeltaEvent, - PartStartEvent, - ToolReturnPart, - RetryPromptPart, - FinalResultEvent, - ThinkingPartDelta, - ToolCallPartDelta, - FunctionToolCallEvent, - FunctionToolResultEvent, -) - -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, -) - - -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 TestTracingHandlerSync: - """The sync converter has the same opt-in tracing-handler contract as the - async streamer: pass a handler and the converter calls ``on_tool_start`` / - ``on_tool_end`` for each tool call. Streaming yields are unchanged when - omitted.""" - - class _RecordingHandler: - def __init__(self) -> None: - self.starts: list[dict[str, Any]] = [] - self.ends: list[dict[str, Any]] = [] - - async def on_tool_start(self, tool_call_id: str, tool_name: str, arguments: Any) -> None: - self.starts.append({"tool_call_id": tool_call_id, "tool_name": tool_name, "arguments": arguments}) - - async def on_tool_end(self, tool_call_id: str, result: Any) -> None: - self.ends.append({"tool_call_id": tool_call_id, "result": result}) - - async def test_handler_records_start_and_end_for_a_tool_call(self): - handler = self._RecordingHandler() - events = [ - PartStartEvent( - index=0, - part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="c1"), - ), - PartEndEvent( - index=0, - part=ToolCallPart(tool_name="get_weather", args='{"city":"Paris"}', tool_call_id="c1"), - ), - FunctionToolResultEvent( - part=ToolReturnPart(tool_name="get_weather", content="Sunny", tool_call_id="c1"), - ), - ] - out = await _collect( - convert_pydantic_ai_to_agentex_events(_aiter(events), tracing_handler=handler) # type: ignore[arg-type] - ) - - # Streaming output is unchanged. - assert any(isinstance(e, StreamTaskMessageStart) for e in out) - assert any(isinstance(e, StreamTaskMessageFull) for e in out) - - assert handler.starts == [ - { - "tool_call_id": "c1", - "tool_name": "get_weather", - "arguments": {"city": "Paris"}, - } - ] - assert handler.ends == [{"tool_call_id": "c1", "result": "Sunny"}] - - async def test_handler_not_called_when_no_tool_calls(self): - handler = self._RecordingHandler() - events = [ - PartStartEvent(index=0, part=TextPart(content="")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hi")), - PartEndEvent(index=0, part=TextPart(content="hi")), - ] - await _collect( - convert_pydantic_ai_to_agentex_events(_aiter(events), tracing_handler=handler) # type: ignore[arg-type] - ) - assert handler.starts == [] - assert handler.ends == [] - - async def test_omitting_handler_preserves_pre_tracing_behavior(self): - events = [ - PartStartEvent( - index=0, - part=ToolCallPart(tool_name="t", args=None, tool_call_id="c"), - ), - PartEndEvent( - index=0, - part=ToolCallPart(tool_name="t", args="{}", tool_call_id="c"), - ), - FunctionToolResultEvent( - part=ToolReturnPart(tool_name="t", content="ok", tool_call_id="c"), - ), - ] - out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) - # Same emit shape as before: Start, Done, Full - types = [type(e).__name__ for e in out] - assert "StreamTaskMessageStart" in types - assert "StreamTaskMessageDone" in types - assert "StreamTaskMessageFull" in types - - -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" diff --git a/tests/lib/adk/test_tasks_activities.py b/tests/lib/adk/test_tasks_activities.py deleted file mode 100644 index 3c9505de0..000000000 --- a/tests/lib/adk/test_tasks_activities.py +++ /dev/null @@ -1,249 +0,0 @@ -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 deleted file mode 100644 index f72e50333..000000000 --- a/tests/lib/adk/test_tasks_module.py +++ /dev/null @@ -1,254 +0,0 @@ -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 deleted file mode 100644 index 8fd988070..000000000 --- a/tests/lib/adk/test_tasks_service.py +++ /dev/null @@ -1,159 +0,0 @@ -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 deleted file mode 100644 index 248ba94a7..000000000 --- a/tests/lib/adk/test_tracing_activities.py +++ /dev/null @@ -1,96 +0,0 @@ -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 deleted file mode 100644 index 52d5d3f82..000000000 --- a/tests/lib/adk/test_tracing_module.py +++ /dev/null @@ -1,117 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timezone -from unittest.mock import AsyncMock, patch - -import agentex.lib.adk._modules.tracing as _tracing_mod -from agentex.types.span import Span -from agentex.lib.adk._modules.tracing import TracingModule -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 - - -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 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_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() diff --git a/tests/lib/adk/test_tracing_service.py b/tests/lib/adk/test_tracing_service.py deleted file mode 100644 index dceb000f5..000000000 --- a/tests/lib/adk/test_tracing_service.py +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/lib/cli/test_agent_handlers.py b/tests/lib/cli/test_agent_handlers.py deleted file mode 100644 index 73c29cfbb..000000000 --- a/tests/lib/cli/test_agent_handlers.py +++ /dev/null @@ -1,424 +0,0 @@ -"""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 deleted file mode 100644 index 4c42e781e..000000000 --- a/tests/lib/cli/test_environment_config.py +++ /dev/null @@ -1,345 +0,0 @@ -"""Tests for AgentEnvironmentsConfig.""" - -import tempfile - -import pytest - -from agentex.lib.sdk.config.environment_config import ( - AgentAuthConfig, - AgentKubernetesConfig, - AgentEnvironmentConfig, - AgentEnvironmentsConfig, -) - - -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 TestAgentEnvironmentsConfigFromYaml: - """Test cases for AgentEnvironmentsConfig.from_yaml method.""" - - 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 = AgentEnvironmentsConfig.from_yaml(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 = AgentEnvironmentsConfig.from_yaml(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 = AgentEnvironmentsConfig.from_yaml(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 = AgentEnvironmentsConfig.from_yaml(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 = AgentEnvironmentsConfig.from_yaml(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"): - AgentEnvironmentsConfig.from_yaml("/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"): - AgentEnvironmentsConfig.from_yaml(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"): - AgentEnvironmentsConfig.from_yaml(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"): - AgentEnvironmentsConfig.from_yaml(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 = AgentEnvironmentsConfig.from_yaml(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 = AgentEnvironmentsConfig.from_yaml(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 deleted file mode 100644 index ec809cbbf..000000000 --- a/tests/lib/cli/test_init_templates.py +++ /dev/null @@ -1,139 +0,0 @@ -"""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_validation.py b/tests/lib/cli/test_validation.py deleted file mode 100644 index c921d46e3..000000000 --- a/tests/lib/cli/test_validation.py +++ /dev/null @@ -1,76 +0,0 @@ -"""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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/lib/core/services/__init__.py b/tests/lib/core/services/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/lib/core/services/adk/__init__.py b/tests/lib/core/services/adk/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/lib/core/services/adk/test_streaming.py b/tests/lib/core/services/adk/test_streaming.py deleted file mode 100644 index b07c55f74..000000000 --- a/tests/lib/core/services/adk/test_streaming.py +++ /dev/null @@ -1,522 +0,0 @@ -"""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 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 == [] - - -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 diff --git a/tests/lib/core/tracing/__init__.py b/tests/lib/core/tracing/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/lib/core/tracing/processors/__init__.py b/tests/lib/core/tracing/processors/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/lib/core/tracing/processors/test_agentex_tracing_processor.py b/tests/lib/core/tracing/processors/test_agentex_tracing_processor.py deleted file mode 100644 index ec1ed5e88..000000000 --- a/tests/lib/core/tracing/processors/test_agentex_tracing_processor.py +++ /dev/null @@ -1,132 +0,0 @@ -from __future__ import annotations - -import asyncio -import weakref -from unittest.mock import 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" - - -def _make_config() -> MagicMock: - """Empty config โ€” AgentexTracingProcessorConfig is unused by __init__.""" - return MagicMock() - - -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 deleted file mode 100644 index 41efcea5a..000000000 --- a/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py +++ /dev/null @@ -1,402 +0,0 @@ -from __future__ import annotations - -import uuid -import asyncio -from datetime import UTC, datetime -from unittest.mock import AsyncMock, MagicMock, patch - -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 - - -# --------------------------------------------------------------------------- -# 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) - 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): - """Each span produces one flush on start and one on end.""" - 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 - - -# --------------------------------------------------------------------------- -# 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) - 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): - """Each span produces one upsert_batch call on start and one on end.""" - 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_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): - """on_span_end should send the span's current input and output via upsert_batch.""" - 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): - """Given N spans at once, on_spans_start 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) - - 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") - 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) - - 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) - - 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 " - 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 - import weakref - - mock_env = MagicMock() - mock_env.refresh.return_value = MagicMock(ACP_TYPE=None, AGENT_NAME=None, AGENT_ID=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) - - 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 diff --git a/tests/lib/core/tracing/processors/test_tracing_processor_interface.py b/tests/lib/core/tracing/processors/test_tracing_processor_interface.py deleted file mode 100644 index 12847b70d..000000000 --- a/tests/lib/core/tracing/processors/test_tracing_processor_interface.py +++ /dev/null @@ -1,98 +0,0 @@ -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_span_queue.py b/tests/lib/core/tracing/test_span_queue.py deleted file mode 100644 index d2452d619..000000000 --- a/tests/lib/core/tracing/test_span_queue.py +++ /dev/null @@ -1,861 +0,0 @@ -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 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() diff --git a/tests/lib/core/tracing/test_span_queue_load.py b/tests/lib/core/tracing/test_span_queue_load.py deleted file mode 100644 index 652589881..000000000 --- a/tests/lib/core/tracing/test_span_queue_load.py +++ /dev/null @@ -1,306 +0,0 @@ -""" -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_trace_task_id.py b/tests/lib/core/tracing/test_trace_task_id.py deleted file mode 100644 index 1a616cc94..000000000 --- a/tests/lib/core/tracing/test_trace_task_id.py +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index ccde4d33c..000000000 --- a/tests/lib/test_agent_card.py +++ /dev/null @@ -1,397 +0,0 @@ -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 - - 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 - - -# --- 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_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 - - -# --- 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.get_build_info", return_value={"version": "1.0"}): - 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"] - assert metadata["version"] == "1.0" - - async def test_none_preserved_when_no_card_no_build_info(self, mock_env_vars): - mock_client = self._make_mock_client() - - with patch("agentex.lib.utils.registration.get_build_info", return_value=None): - 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_when_build_info_none(self, mock_env_vars): - card = AgentCard(input_types=["text"]) - mock_client = self._make_mock_client() - - with patch("agentex.lib.utils.registration.get_build_info", return_value=None): - 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 deleted file mode 100644 index 76347f0d5..000000000 --- a/tests/lib/test_agentex_worker.py +++ /dev/null @@ -1,90 +0,0 @@ -import os -from unittest.mock import 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 == [] diff --git a/tests/lib/test_auto_send_params_created_at.py b/tests/lib/test_auto_send_params_created_at.py deleted file mode 100644 index f13041bea..000000000 --- a/tests/lib/test_auto_send_params_created_at.py +++ /dev/null @@ -1,101 +0,0 @@ -"""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_claude_agents_activities.py b/tests/lib/test_claude_agents_activities.py deleted file mode 100644 index fa633cd6b..000000000 --- a/tests/lib/test_claude_agents_activities.py +++ /dev/null @@ -1,728 +0,0 @@ -"""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 -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 - -# 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) - -_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 deleted file mode 100644 index 373956680..000000000 --- a/tests/lib/test_claude_agents_hooks.py +++ /dev/null @@ -1,390 +0,0 @@ -"""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 -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 - -# 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) - -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_payload_codec.py b/tests/lib/test_payload_codec.py deleted file mode 100644 index 59736dc21..000000000 --- a/tests/lib/test_payload_codec.py +++ /dev/null @@ -1,385 +0,0 @@ -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_temporal_utils.py b/tests/lib/test_temporal_utils.py deleted file mode 100644 index b06bdfb3c..000000000 --- a/tests/lib/test_temporal_utils.py +++ /dev/null @@ -1,30 +0,0 @@ -"""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/test_client.py b/tests/test_client.py index 131d32fee..66a8716ce 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,14 +6,11 @@ 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 @@ -24,14 +21,17 @@ from agentex import Agentex, AsyncAgentex, APIResponseValidationError from agentex._types import Omit +from agentex._utils import asyncify from agentex._models import BaseModel, FinalRequestOptions from agentex._exceptions import APIStatusError, APITimeoutError, APIResponseValidationError from agentex._base_client import ( DEFAULT_TIMEOUT, HTTPX_DEFAULT_TIMEOUT, BaseClient, + OtherPlatform, DefaultHttpxClient, DefaultAsyncHttpxClient, + get_platform, make_request_options, ) @@ -1920,50 +1920,9 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: 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_get_platform(self) -> None: + platform = await asyncify(get_platform)() + assert isinstance(platform, (str, OtherPlatform)) async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: # Test that the proxy environment variables are set correctly diff --git a/tests/test_function_tool.py b/tests/test_function_tool.py deleted file mode 100644 index 91312e227..000000000 --- a/tests/test_function_tool.py +++ /dev/null @@ -1,256 +0,0 @@ -from __future__ import annotations - -import json -from typing import Any, override - -import pytest -from pydantic import ValidationError - -from src.agentex.lib.core.temporal.activities.adk.providers.openai_activities import ( # type: ignore[import-untyped] - 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 deleted file mode 100644 index 596c6729c..000000000 --- a/tests/test_header_forwarding.py +++ /dev/null @@ -1,541 +0,0 @@ -# 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 deleted file mode 100644 index 9c570223d..000000000 --- a/tests/test_model_utils.py +++ /dev/null @@ -1,226 +0,0 @@ -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: - """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_task_cancel.py b/tests/test_task_cancel.py deleted file mode 100644 index aaa2c44f2..000000000 --- a/tests/test_task_cancel.py +++ /dev/null @@ -1,41 +0,0 @@ -"""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/uv.lock b/uv.lock index 3ac132863..53adf62f3 100644 --- a/uv.lock +++ b/uv.lock @@ -1,55 +1,32 @@ version = 1 revision = 3 -requires-python = ">=3.11, <4" +requires-python = ">=3.9" resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", - "python_full_version < '3.12'", + "python_full_version >= '3.14' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version >= '3.10' and python_full_version < '3.14' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version < '3.10' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version >= '3.10' and extra == 'group-11-agentex-sdk-pydantic-v1' and extra != 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version < '3.10' and extra == 'group-11-agentex-sdk-pydantic-v1' and extra != 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version >= '3.10' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra != 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version < '3.10' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra != 'group-11-agentex-sdk-pydantic-v2'", ] +conflicts = [[ + { package = "agentex-sdk", group = "pydantic-v1" }, + { package = "agentex-sdk", group = "pydantic-v2" }, +]] [[package]] name = "agentex-sdk" -version = "0.11.9" +version = "0.12.0" source = { editable = "." } dependencies = [ - { name = "aiohttp" }, { name = "anyio" }, - { name = "claude-agent-sdk" }, - { name = "cloudpickle" }, - { name = "ddtrace" }, { name = "distro" }, - { name = "fastapi" }, { name = "httpx" }, - { 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" }, - { name = "pydantic-ai-slim" }, - { name = "python-on-whales" }, - { name = "pyyaml" }, - { name = "questionary" }, - { name = "redis" }, - { name = "rich" }, - { name = "scale-gp" }, - { name = "scale-gp-beta" }, + { name = "pydantic", version = "1.10.26", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-11-agentex-sdk-pydantic-v1'" }, + { name = "pydantic", version = "2.12.5", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-11-agentex-sdk-pydantic-v2' or extra != 'group-11-agentex-sdk-pydantic-v1'" }, { name = "sniffio" }, - { name = "starlette" }, - { name = "temporalio" }, - { name = "typer" }, { name = "typing-extensions" }, - { name = "uvicorn" }, - { name = "watchfiles" }, - { name = "yaspin" }, ] [package.optional-dependencies] @@ -57,84 +34,49 @@ 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", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, + { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, + { name = "pytest-asyncio", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, { name = "pytest-xdist" }, { name = "respx" }, { name = "rich" }, { name = "ruff" }, - { name = "time-machine" }, - { name = "yaspin" }, + { name = "time-machine", version = "2.19.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, + { name = "time-machine", version = "3.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, +] +pydantic-v1 = [ + { name = "pydantic", version = "1.10.26", source = { registry = "https://pypi.org/simple" } }, +] +pydantic-v2 = [ + { name = "pydantic", version = "2.12.5", source = { registry = "https://pypi.org/simple" } }, ] [package.metadata] requires-dist = [ - { name = "aiohttp", specifier = ">=3.10.10,<4" }, { name = "aiohttp", marker = "extra == 'aiohttp'" }, { name = "anyio", specifier = ">=3.5.0,<5" }, - { name = "claude-agent-sdk", specifier = ">=0.1.0" }, - { name = "cloudpickle", specifier = ">=3.1.1" }, - { name = "ddtrace", specifier = ">=3.13.0" }, { name = "distro", specifier = ">=1.7.0,<2" }, - { name = "fastapi", specifier = ">=0.115.0" }, - { name = "httpx", specifier = ">=0.28.1,<0.29" }, + { name = "httpx", specifier = ">=0.23.0,<1" }, { name = "httpx-aiohttp", marker = "extra == 'aiohttp'", specifier = ">=0.1.9" }, - { 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,<3" }, - { 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", specifier = ">=2.0.0,<3" }, - { 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 = "ruff", marker = "extra == 'dev'", specifier = ">=0.3.4" }, - { name = "scale-gp", specifier = ">=0.1.0a59" }, - { name = "scale-gp-beta", specifier = ">=0.2.0" }, + { name = "pydantic", specifier = ">=1.9.0,<3" }, { name = "sniffio" }, - { name = "starlette", specifier = ">=0.49.1" }, - { name = "temporalio", specifier = ">=1.26.0,<2" }, - { name = "typer", specifier = ">=0.16,<0.17" }, { name = "typing-extensions", specifier = ">=4.14,<5" }, - { name = "uvicorn", specifier = ">=0.31.1" }, - { name = "watchfiles", specifier = ">=0.24.0,<1.0" }, - { name = "yaspin", specifier = ">=3.1.0" }, ] -provides-extras = ["aiohttp", "dev"] +provides-extras = ["aiohttp"] [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" }, @@ -143,7 +85,11 @@ dev = [ { name = "rich", specifier = ">=13.7.1" }, { name = "ruff" }, { name = "time-machine" }, - { name = "yaspin", specifier = ">=3.1.0" }, +] +pydantic-v1 = [{ name = "pydantic", specifier = ">=1.9.0,<2" }] +pydantic-v2 = [ + { name = "pydantic", marker = "python_full_version < '3.14'", specifier = "~=2.0" }, + { name = "pydantic", marker = "python_full_version >= '3.14'", specifier = "~=2.12" }, ] [[package]] @@ -162,6 +108,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, { name = "aiosignal" }, + { name = "async-timeout", marker = "python_full_version < '3.11' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, { name = "attrs" }, { name = "frozenlist" }, { name = "multidict" }, @@ -170,6 +117,23 @@ dependencies = [ ] 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/36/d6/5aec9313ee6ea9c7cde8b891b69f4ff4001416867104580670a31daeba5b/aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7", size = 738950, upload-time = "2026-01-03T17:29:13.002Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/8fa90a7e6d11ff20a18837a8e2b5dd23db01aabc475aa9271c8ad33299f5/aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821", size = 496099, upload-time = "2026-01-03T17:29:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/b81f744d402510a8366b74eb420fc0cc1170d0c43daca12d10814df85f10/aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845", size = 491072, upload-time = "2026-01-03T17:29:16.922Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e1/56d1d1c0dd334cd203dd97706ce004c1aa24b34a813b0b8daf3383039706/aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af", size = 1671588, upload-time = "2026-01-03T17:29:18.539Z" }, + { url = "https://files.pythonhosted.org/packages/5f/34/8d7f962604f4bc2b4e39eb1220dac7d4e4cba91fb9ba0474b4ecd67db165/aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940", size = 1640334, upload-time = "2026-01-03T17:29:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/94/1d/fcccf2c668d87337ddeef9881537baee13c58d8f01f12ba8a24215f2b804/aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160", size = 1722656, upload-time = "2026-01-03T17:29:22.531Z" }, + { url = "https://files.pythonhosted.org/packages/aa/98/c6f3b081c4c606bc1e5f2ec102e87d6411c73a9ef3616fea6f2d5c98c062/aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7", size = 1817625, upload-time = "2026-01-03T17:29:24.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c0/cfcc3d2e11b477f86e1af2863f3858c8850d751ce8dc39c4058a072c9e54/aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455", size = 1672604, upload-time = "2026-01-03T17:29:26.099Z" }, + { url = "https://files.pythonhosted.org/packages/1e/77/6b4ffcbcac4c6a5d041343a756f34a6dd26174ae07f977a64fe028dda5b0/aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279", size = 1554370, upload-time = "2026-01-03T17:29:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f0/e3ddfa93f17d689dbe014ba048f18e0c9f9b456033b70e94349a2e9048be/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e", size = 1642023, upload-time = "2026-01-03T17:29:30.002Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/c14019c9ec60a8e243d06d601b33dcc4fd92379424bde3021725859d7f99/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d", size = 1649680, upload-time = "2026-01-03T17:29:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fd/09c9451dae5aa5c5ed756df95ff9ef549d45d4be663bafd1e4954fd836f0/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808", size = 1692407, upload-time = "2026-01-03T17:29:33.392Z" }, + { url = "https://files.pythonhosted.org/packages/a6/81/938bc2ec33c10efd6637ccb3d22f9f3160d08e8f3aa2587a2c2d5ab578eb/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40", size = 1543047, upload-time = "2026-01-03T17:29:34.855Z" }, + { url = "https://files.pythonhosted.org/packages/f7/23/80488ee21c8d567c83045e412e1d9b7077d27171591a4eb7822586e8c06a/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29", size = 1715264, upload-time = "2026-01-03T17:29:36.389Z" }, + { url = "https://files.pythonhosted.org/packages/e2/83/259a8da6683182768200b368120ab3deff5370bed93880fb9a3a86299f34/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11", size = 1657275, upload-time = "2026-01-03T17:29:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4f/2c41f800a0b560785c10fb316216ac058c105f9be50bdc6a285de88db625/aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd", size = 434053, upload-time = "2026-01-03T17:29:40.074Z" }, + { url = "https://files.pythonhosted.org/packages/80/df/29cd63c7ecfdb65ccc12f7d808cac4fa2a19544660c06c61a4a48462de0c/aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c", size = 456687, upload-time = "2026-01-03T17:29:41.819Z" }, { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, @@ -255,6 +219,23 @@ wheels = [ { 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" }, + { url = "https://files.pythonhosted.org/packages/bf/79/446655656861d3e7e2c32bfcf160c7aa9e9dc63776a691b124dba65cdd77/aiohttp-3.13.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:31a83ea4aead760dfcb6962efb1d861db48c34379f2ff72db9ddddd4cda9ea2e", size = 741433, upload-time = "2026-01-03T17:32:26.453Z" }, + { url = "https://files.pythonhosted.org/packages/cb/49/773c4b310b5140d2fb5e79bb0bf40b7b41dad80a288ca1a8759f5f72bda9/aiohttp-3.13.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:988a8c5e317544fdf0d39871559e67b6341065b87fceac641108c2096d5506b7", size = 497332, upload-time = "2026-01-03T17:32:28.37Z" }, + { url = "https://files.pythonhosted.org/packages/bc/31/1dcbc4b83a4e6f76a0ad883f07f21ffbfe29750c89db97381701508c9f45/aiohttp-3.13.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9b174f267b5cfb9a7dba9ee6859cecd234e9a681841eb85068059bc867fb8f02", size = 492365, upload-time = "2026-01-03T17:32:30.234Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b5/b50657496c8754482cd7964e50aaf3aa84b3db61ed45daec4c1aec5b94b4/aiohttp-3.13.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:947c26539750deeaee933b000fb6517cc770bbd064bad6033f1cff4803881e43", size = 1660440, upload-time = "2026-01-03T17:32:32.586Z" }, + { url = "https://files.pythonhosted.org/packages/2a/73/9b69e5139d89d75127569298931444ad78ea86a5befd5599780b1e9a6880/aiohttp-3.13.3-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9ebf57d09e131f5323464bd347135a88622d1c0976e88ce15b670e7ad57e4bd6", size = 1632740, upload-time = "2026-01-03T17:32:34.793Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fe/3ea9b5af694b4e3aec0d0613a806132ca744747146fca68e96bf056f61a7/aiohttp-3.13.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4ae5b5a0e1926e504c81c5b84353e7a5516d8778fbbff00429fe7b05bb25cbce", size = 1719782, upload-time = "2026-01-03T17:32:37.737Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/46b3b06e60851cbb71efb0f79a3267279cbef7b12c58e68a1e897f269cca/aiohttp-3.13.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ba0eea45eb5cc3172dbfc497c066f19c41bac70963ea1a67d51fc92e4cf9a80", size = 1813527, upload-time = "2026-01-03T17:32:39.973Z" }, + { url = "https://files.pythonhosted.org/packages/36/23/71ceb78c769ed65fe4c697692de232b63dab399210678d2b00961ccb0619/aiohttp-3.13.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bae5c2ed2eae26cc382020edad80d01f36cb8e746da40b292e68fec40421dc6a", size = 1661268, upload-time = "2026-01-03T17:32:42.082Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/86e929523d955e85ebab7c0e2b9e0cb63604cfc27dc3280e10d0063cf682/aiohttp-3.13.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a60e60746623925eab7d25823329941aee7242d559baa119ca2b253c88a7bd6", size = 1552742, upload-time = "2026-01-03T17:32:44.622Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/3f5987cba1bab6bd151f0d97aa60f0ce04d3c83316692a6bb6ba2fb69f92/aiohttp-3.13.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e50a2e1404f063427c9d027378472316201a2290959a295169bcf25992d04558", size = 1632918, upload-time = "2026-01-03T17:32:46.749Z" }, + { url = "https://files.pythonhosted.org/packages/be/2c/7e1e85121f2e31ee938cb83a8f32dfafd4908530c10fabd6d46761c12ac7/aiohttp-3.13.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:9a9dc347e5a3dc7dfdbc1f82da0ef29e388ddb2ed281bfce9dd8248a313e62b7", size = 1644446, upload-time = "2026-01-03T17:32:49.063Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/ce6133d423ad0e8ca976a7c848f7146bca3520eea4ccf6b95e2d077c9d20/aiohttp-3.13.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b46020d11d23fe16551466c77823df9cc2f2c1e63cc965daf67fa5eec6ca1877", size = 1689487, upload-time = "2026-01-03T17:32:51.113Z" }, + { url = "https://files.pythonhosted.org/packages/50/f7/ff7a27c15603d460fd1366b3c22054f7ae4fa9310aca40b43bde35867fcd/aiohttp-3.13.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:69c56fbc1993fa17043e24a546959c0178fe2b5782405ad4559e6c13975c15e3", size = 1540715, upload-time = "2026-01-03T17:32:53.38Z" }, + { url = "https://files.pythonhosted.org/packages/17/02/053f11346e5b962e6d8a1c4f8c70c29d5970a1b4b8e7894c68e12c27a57f/aiohttp-3.13.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:b99281b0704c103d4e11e72a76f1b543d4946fea7dd10767e7e1b5f00d4e5704", size = 1711835, upload-time = "2026-01-03T17:32:56.088Z" }, + { url = "https://files.pythonhosted.org/packages/fb/71/9b9761ddf276fd6708d13720197cbac19b8d67ecfa9116777924056cfcaa/aiohttp-3.13.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:40c5e40ecc29ba010656c18052b877a1c28f84344825efa106705e835c28530f", size = 1649593, upload-time = "2026-01-03T17:32:58.181Z" }, + { url = "https://files.pythonhosted.org/packages/ae/72/5d817e9ea218acae12a5e3b9ad1178cf0c12fc3570c0b47eea2daf95f9ea/aiohttp-3.13.3-cp39-cp39-win32.whl", hash = "sha256:56339a36b9f1fc708260c76c87e593e2afb30d26de9ae1eb445b5e051b98a7a1", size = 434831, upload-time = "2026-01-03T17:33:00.577Z" }, + { url = "https://files.pythonhosted.org/packages/39/cb/22659d9bf3149b7a2927bc2769cc9c8f8f5a80eba098398e03c199a43a85/aiohttp-3.13.3-cp39-cp39-win_amd64.whl", hash = "sha256:c6b8568a3bb5819a0ad087f16d40e5a3fb6099f39ea1d5625a3edc1e923fc538", size = 457697, upload-time = "2026-01-03T17:33:03.167Z" }, ] [[package]] @@ -263,22 +244,13 @@ version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "frozenlist" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, ] 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" @@ -293,23 +265,15 @@ name = "anyio" version = "4.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, ] 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 = "async-timeout" version = "5.0.1" @@ -329,12 +293,12 @@ wheels = [ ] [[package]] -name = "bytecode" -version = "0.17.0" +name = "backports-asyncio-runner" +version = "1.2.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" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] [[package]] @@ -346,204 +310,6 @@ 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/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, - { 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/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, - { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, - { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, - { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, - { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, - { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, - { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, - { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, - { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, - { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, - { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, - { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, - { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, - { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, - { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, - { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, - { 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" @@ -553,158 +319,6 @@ 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" }, - { url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" }, - { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, - { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, - { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, - { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, - { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, -] - -[[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/8c/fa/88eed2098c1116d4660db153f6b97f02a7e46ee1a7eaf4c033837abe15ae/ddtrace-4.10.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:7b7a537009724f4fcedbd3222160e87605988a2dc1e9c9af38d6e42b6fa116e1", size = 6978423, upload-time = "2026-06-01T17:52:10.296Z" }, - { url = "https://files.pythonhosted.org/packages/f6/86/12e00f9530875ea96777a94a512c0b63f5220d22af0bed8de405ba667fba/ddtrace-4.10.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:b27f750b15bf6e11ce634b2ee835b7b7376f6532825200a9caf389e9084c22d9", size = 7291922, upload-time = "2026-06-01T17:52:12.314Z" }, - { url = "https://files.pythonhosted.org/packages/59/06/353d0bea6eac1b9a69a0c4caad263d6536631969bf8a63105f2a3cfa39aa/ddtrace-4.10.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b3bfab9191ea981a53f6b6b5bac2f25ad85b4ae72646dd19d92edca57e94726", size = 8415085, upload-time = "2026-06-01T17:52:14.482Z" }, - { url = "https://files.pythonhosted.org/packages/bf/ca/d5a65aac1059503e65c8b836c27f0b1f7ac8b747bfb884ad599509fbc96f/ddtrace-4.10.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c9fc0197a3d872381f7094f4291ce095ca92dde45ccabe77817ca92457407cba", size = 8632962, upload-time = "2026-06-01T17:52:16.922Z" }, - { url = "https://files.pythonhosted.org/packages/bf/61/d7eafa23a87183b2f8b90a87e026bc19687848d628e704523304e5b7cce0/ddtrace-4.10.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1d1ef4e0977b5ae60c83b39ab6540c059ff4df44df86e7987b965aebe832af4", size = 9418684, upload-time = "2026-06-01T17:52:19.14Z" }, - { url = "https://files.pythonhosted.org/packages/c9/c8/074ba0114d2dd75ab4f323c5257464f364682f8f93883d5ac112248b7d51/ddtrace-4.10.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c47d1b22815e5b6aea564b14293b25b8045ec7b20752804c284b93b12908de9", size = 9678258, upload-time = "2026-06-01T17:52:21.662Z" }, - { url = "https://files.pythonhosted.org/packages/74/23/a4b65506ce490b0b1fb5223eaeb86c99537e2728cf4e01d756c16c0440c2/ddtrace-4.10.1-cp311-cp311-win32.whl", hash = "sha256:910a7e1bad20285ac9d8df88ede5dcd0271bc3477efe499b6bc5a962b8dc9b35", size = 5581865, upload-time = "2026-06-01T17:52:24.417Z" }, - { url = "https://files.pythonhosted.org/packages/b9/4e/56da38b1bf74beeadcebc9778d52241a309f4bba4707469cbc67ae52ab48/ddtrace-4.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:a8850a7c9ecd715316886fcf88587663e262df46e7020151ead18053ec6278ec", size = 6161203, upload-time = "2026-06-01T17:52:26.643Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/725c87159847597e5e2a5e17ae27e4b3bc34be5b09df6e56b40e01d5b079/ddtrace-4.10.1-cp311-cp311-win_arm64.whl", hash = "sha256:b2472ae8b90e4d7fc0f4fb381284eb08d9eb113df3aba7b36997633bd80f0e7e", size = 5844187, upload-time = "2026-06-01T17:52:29.015Z" }, - { 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/89/fb/cbf306d6e07a313a91e7171a98669054502840931432c227cfd505ee367f/debugpy-1.8.21-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:da456226c7b4c69e35dbe35dcee6623d912000a77816db7856a41af1c72a0264", size = 2203120, upload-time = "2026-06-01T19:30:43.964Z" }, - { url = "https://files.pythonhosted.org/packages/aa/57/aa739bd4ad2cbf96aeb1b20b56918ddd5ae4c28b68709bfcd327f02123ee/debugpy-1.8.21-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:f68b891688e61bdc08b8d364d919ff0051e0b94657b39dcd027bc3173edb7cdc", size = 3059958, upload-time = "2026-06-01T19:30:45.622Z" }, - { url = "https://files.pythonhosted.org/packages/a8/31/453d2c9a23d133fe2c8ec7ca1d816ded52a913487fe3ffef7c01b4b706af/debugpy-1.8.21-cp311-cp311-win32.whl", hash = "sha256:f843a8b08c2edeaf9b1582eed4f25441af21a297c22ff16bf76a662557aa9c9e", size = 5236515, upload-time = "2026-06-01T19:30:47.461Z" }, - { url = "https://files.pythonhosted.org/packages/60/94/6660de2f2d7bf388f229335ba4637646eebabdbf38564cb439a95a9193c9/debugpy-1.8.21-cp311-cp311-win_amd64.whl", hash = "sha256:84c564d8cc701d41843b29a92814c1f1bef6798724ca9d675c284ad9f6a547d7", size = 5256138, upload-time = "2026-06-01T19:30:49.113Z" }, - { 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" @@ -724,21 +338,15 @@ wheels = [ ] [[package]] -name = "durationpy" -version = "0.10" +name = "exceptiongroup" +version = "1.3.1" 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" }, +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, ] - -[[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" } +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] [[package]] @@ -750,107 +358,28 @@ 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/98/f3/12481bda4e5b6d3e698fbf525df4443cc7dce746f246b86b6fcb2fba1844/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:73946cb950c8caf65127d4e9a325e2b6be0442a224fd51ba3b6ac44e1912ce34", size = 516386, upload-time = "2025-10-19T22:42:40.176Z" }, - { url = "https://files.pythonhosted.org/packages/59/19/2fc58a1446e4d72b655648eb0879b04e88ed6fa70d474efcf550f640f6ec/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:12ac85024637586a5b69645e7ed986f7535106ed3013640a393a03e461740cb7", size = 264569, upload-time = "2025-10-19T22:25:50.977Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/3c74756e5b02c40cfcc8b1d8b5bac4edbd532b55917a6bcc9113550e99d1/fastuuid-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:05a8dde1f395e0c9b4be515b7a521403d1e8349443e7641761af07c7ad1624b1", size = 254366, upload-time = "2025-10-19T22:29:49.166Z" }, - { url = "https://files.pythonhosted.org/packages/52/96/d761da3fccfa84f0f353ce6e3eb8b7f76b3aa21fd25e1b00a19f9c80a063/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09378a05020e3e4883dfdab438926f31fea15fd17604908f3d39cbeb22a0b4dc", size = 278978, upload-time = "2025-10-19T22:35:41.306Z" }, - { url = "https://files.pythonhosted.org/packages/fc/c2/f84c90167cc7765cb82b3ff7808057608b21c14a38531845d933a4637307/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbb0c4b15d66b435d2538f3827f05e44e2baafcc003dd7d8472dc67807ab8fd8", size = 279692, upload-time = "2025-10-19T22:25:36.997Z" }, - { url = "https://files.pythonhosted.org/packages/af/7b/4bacd03897b88c12348e7bd77943bac32ccf80ff98100598fcff74f75f2e/fastuuid-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cd5a7f648d4365b41dbf0e38fe8da4884e57bed4e77c83598e076ac0c93995e7", size = 303384, upload-time = "2025-10-19T22:29:46.578Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a2/584f2c29641df8bd810d00c1f21d408c12e9ad0c0dafdb8b7b29e5ddf787/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c0a94245afae4d7af8c43b3159d5e3934c53f47140be0be624b96acd672ceb73", size = 460921, upload-time = "2025-10-19T22:36:42.006Z" }, - { url = "https://files.pythonhosted.org/packages/24/68/c6b77443bb7764c760e211002c8638c0c7cce11cb584927e723215ba1398/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:2b29e23c97e77c3a9514d70ce343571e469098ac7f5a269320a0f0b3e193ab36", size = 480575, upload-time = "2025-10-19T22:28:18.975Z" }, - { url = "https://files.pythonhosted.org/packages/5a/87/93f553111b33f9bb83145be12868c3c475bf8ea87c107063d01377cc0e8e/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1e690d48f923c253f28151b3a6b4e335f2b06bf669c68a02665bc150b7839e94", size = 452317, upload-time = "2025-10-19T22:25:32.75Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8c/a04d486ca55b5abb7eaa65b39df8d891b7b1635b22db2163734dc273579a/fastuuid-0.14.0-cp311-cp311-win32.whl", hash = "sha256:a6f46790d59ab38c6aa0e35c681c0484b50dc0acf9e2679c005d61e019313c24", size = 154804, upload-time = "2025-10-19T22:24:15.615Z" }, - { url = "https://files.pythonhosted.org/packages/9c/b2/2d40bf00820de94b9280366a122cbaa60090c8cf59e89ac3938cf5d75895/fastuuid-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:e150eab56c95dc9e3fefc234a0eedb342fac433dacc273cd4d150a5b0871e1fa", size = 156099, upload-time = "2025-10-19T22:24:31.646Z" }, - { 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/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, + { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, + { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, @@ -947,40 +476,25 @@ wheels = [ { 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/c2/59/ae5cdac87a00962122ea37bb346d41b66aec05f9ce328fa2b9e216f8967b/frozenlist-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47", size = 86967, upload-time = "2025-10-06T05:37:55.607Z" }, + { url = "https://files.pythonhosted.org/packages/8a/10/17059b2db5a032fd9323c41c39e9d1f5f9d0c8f04d1e4e3e788573086e61/frozenlist-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca", size = 49984, upload-time = "2025-10-06T05:37:57.049Z" }, + { url = "https://files.pythonhosted.org/packages/4b/de/ad9d82ca8e5fa8f0c636e64606553c79e2b859ad253030b62a21fe9986f5/frozenlist-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068", size = 50240, upload-time = "2025-10-06T05:37:58.145Z" }, + { url = "https://files.pythonhosted.org/packages/4e/45/3dfb7767c2a67d123650122b62ce13c731b6c745bc14424eea67678b508c/frozenlist-1.8.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95", size = 219472, upload-time = "2025-10-06T05:37:59.239Z" }, + { url = "https://files.pythonhosted.org/packages/0b/bf/5bf23d913a741b960d5c1dac7c1985d8a2a1d015772b2d18ea168b08e7ff/frozenlist-1.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459", size = 221531, upload-time = "2025-10-06T05:38:00.521Z" }, + { url = "https://files.pythonhosted.org/packages/d0/03/27ec393f3b55860859f4b74cdc8c2a4af3dbf3533305e8eacf48a4fd9a54/frozenlist-1.8.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675", size = 219211, upload-time = "2025-10-06T05:38:01.842Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ad/0fd00c404fa73fe9b169429e9a972d5ed807973c40ab6b3cf9365a33d360/frozenlist-1.8.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61", size = 231775, upload-time = "2025-10-06T05:38:03.384Z" }, + { url = "https://files.pythonhosted.org/packages/8a/c3/86962566154cb4d2995358bc8331bfc4ea19d07db1a96f64935a1607f2b6/frozenlist-1.8.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6", size = 236631, upload-time = "2025-10-06T05:38:04.609Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/6ffad161dbd83782d2c66dc4d378a9103b31770cb1e67febf43aea42d202/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5", size = 218632, upload-time = "2025-10-06T05:38:05.917Z" }, + { url = "https://files.pythonhosted.org/packages/58/b2/4677eee46e0a97f9b30735e6ad0bf6aba3e497986066eb68807ac85cf60f/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3", size = 235967, upload-time = "2025-10-06T05:38:07.614Z" }, + { url = "https://files.pythonhosted.org/packages/05/f3/86e75f8639c5a93745ca7addbbc9de6af56aebb930d233512b17e46f6493/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1", size = 228799, upload-time = "2025-10-06T05:38:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/30/00/39aad3a7f0d98f5eb1d99a3c311215674ed87061aecee7851974b335c050/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178", size = 230566, upload-time = "2025-10-06T05:38:10.52Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4d/aa144cac44568d137846ddc4d5210fb5d9719eb1d7ec6fa2728a54b5b94a/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda", size = 217715, upload-time = "2025-10-06T05:38:11.832Z" }, + { url = "https://files.pythonhosted.org/packages/64/4c/8f665921667509d25a0dd72540513bc86b356c95541686f6442a3283019f/frozenlist-1.8.0-cp39-cp39-win32.whl", hash = "sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087", size = 39933, upload-time = "2025-10-06T05:38:13.061Z" }, + { url = "https://files.pythonhosted.org/packages/79/bd/bcc926f87027fad5e59926ff12d136e1082a115025d33c032d1cd69ab377/frozenlist-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a", size = 44121, upload-time = "2025-10-06T05:38:14.572Z" }, + { url = "https://files.pythonhosted.org/packages/4c/07/9c2e4eb7584af4b705237b971b89a4155a8e57599c4483a131a39256a9a0/frozenlist-1.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103", size = 40312, upload-time = "2025-10-06T05:38:15.699Z" }, { 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" @@ -990,38 +504,6 @@ 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" @@ -1035,19 +517,6 @@ 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" @@ -1076,50 +545,6 @@ 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" @@ -1143,519 +568,100 @@ wheels = [ [[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" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, -] -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" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "parso" }, +resolution-markers = [ + "python_full_version < '3.10'", ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] [[package]] -name = "jinja2" -version = "3.1.6" +name = "iniconfig" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, +resolution-markers = [ + "python_full_version >= '3.14' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version >= '3.10' and python_full_version < '3.14' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version >= '3.10' and extra == 'group-11-agentex-sdk-pydantic-v1' and extra != 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version >= '3.10' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra != 'group-11-agentex-sdk-pydantic-v2'", ] -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" } +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/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, + { 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 = "jiter" -version = "0.15.0" +name = "markdown-it-py" +version = "3.0.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/e4/13/daa722f5765c393576f466378f9dfd29d77c9bed939e0688f96afa3601ea/jiter-0.15.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0f862193b8696249d22ec433e85fd2ab0ad9596bc3e45e6c0bc55e8aeba97be2", size = 310899, upload-time = "2026-05-19T10:07:12.89Z" }, - { url = "https://files.pythonhosted.org/packages/7f/82/2d2551829b082f4b6d82b9f939b031fb808a10aab1ec0664f82e150bb9a2/jiter-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1303d4d68a9b051ea90502402063ecf3807da00ad2affa19ca1ae3b90b3c5f67", size = 314963, upload-time = "2026-05-19T10:07:14.539Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0a/8b1a51466f7fe9f31dbe4bc7e0ca848674f9825e0f737b929b97e8c60aa7/jiter-0.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:392b8ab019e5502d08aff85c6272209c24bc2cbe706ea82a56368f524236614a", size = 341730, upload-time = "2026-05-19T10:07:15.869Z" }, - { url = "https://files.pythonhosted.org/packages/f6/2a/e71dea19822e2e404e83992a08c1d6b9b617bb944f28c9c2fbd85d02c91e/jiter-0.15.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:773b6eb282ce11ee19f05f6b2d4404fa308e5bbd353b0b80a0262caad6db2cd7", size = 366214, upload-time = "2026-05-19T10:07:17.259Z" }, - { url = "https://files.pythonhosted.org/packages/c4/59/97e1fa539d124a509a00ab7f669289d1c1d236ecabf12948a18f16c91082/jiter-0.15.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2c0c44d569ce0f2850f5c926f8caeb5f245fbc84475aeb36efccc2103e6dbd", size = 459527, upload-time = "2026-05-19T10:07:18.741Z" }, - { url = "https://files.pythonhosted.org/packages/d1/7a/4a68d331aef8cf2e2393c14a3aacb635c62aa86071b0229899fb5baaa907/jiter-0.15.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:032396229564bca02440396bd327710719f724f5e7b7e9f7a8eb3faa4a2c2281", size = 375451, upload-time = "2026-05-19T10:07:20.208Z" }, - { url = "https://files.pythonhosted.org/packages/7b/7e/1c445c2b6f0e30a274dc8082e0c3c7825411cce80d726bccd697c98cc8d3/jiter-0.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3d37768fce7f88dd2a8c6091f2325dea27d30d30d5c6e7a1c0f0af77723b708", size = 349428, upload-time = "2026-05-19T10:07:22.372Z" }, - { url = "https://files.pythonhosted.org/packages/00/94/e20d38984fc17a636371bffd2ae0f698124fdc8e75ef969cd2da6ba7cea7/jiter-0.15.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2c9cb907439d20bd0c7d7565ca01ee52234203208433749bae5b516907526928", size = 355405, upload-time = "2026-05-19T10:07:23.916Z" }, - { url = "https://files.pythonhosted.org/packages/94/fa/4d09f814779d0ea80a28ed8e4c6662ec9a4a8ecef0ac52190ebac6262d14/jiter-0.15.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9100ddbec09741cc66feb0fc6773f8bdbd0e3c345689368f260082ff85dcc0cd", size = 393688, upload-time = "2026-05-19T10:07:25.854Z" }, - { url = "https://files.pythonhosted.org/packages/54/9d/8eb5d4fb8bf7e93a75964a5da71a75c67c864baf7fa3f98598187b3c7e57/jiter-0.15.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ae1b0d82ac2d987f9ea512b1c9adfcc71a28de3dea3a6039b54d76cffda9901e", size = 520853, upload-time = "2026-05-19T10:07:27.303Z" }, - { url = "https://files.pythonhosted.org/packages/e7/2c/5e07874e59e623a943a0acf1552a80d05b70f31b402287a8fc6d7ec634c7/jiter-0.15.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8020c99ec13a7db2b6f96cbe82ef4721c88b426a4892f27478044af0284615ef", size = 551016, upload-time = "2026-05-19T10:07:28.846Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/d2d34422143474cadc15b60d482b1c35683dbc5c63c24346ddd0df09bcaf/jiter-0.15.0-cp311-cp311-win32.whl", hash = "sha256:42bfb257930800cf43e7c62c832402c704ab60797c992faf88d20e903eac8f32", size = 209518, upload-time = "2026-05-19T10:07:30.431Z" }, - { url = "https://files.pythonhosted.org/packages/1d/7d/52778b930e5cc3e52a37d950b1c10494244308b4329b25a0ff0d88303a81/jiter-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:860a74063284a2ae9bfedd694f299cc2c68e2696c5f3d440cc9d18bb81b9dd04", size = 200565, upload-time = "2026-05-19T10:07:32.125Z" }, - { url = "https://files.pythonhosted.org/packages/3b/4f/d9b4067feb69b3fa6eb0488e1b59e2ad5b463fe39f59e527eab2aca00bb0/jiter-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:37a10c377ce3a4a85f4a67f28b7afe093154cde77eaf248a72e856aa08b4d865", size = 195488, upload-time = "2026-05-19T10:07:33.846Z" }, - { 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/65/43/1fc62172aa98b50a7de9a25554060db510f85c89cfbed0dfe13e1907a139/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:411fa4dfa5a7ae3d11491027ffb9beadec3996010a986862db70d91abba1c750", size = 305585, upload-time = "2026-05-19T10:09:35.995Z" }, - { url = "https://files.pythonhosted.org/packages/e8/c4/dd58fcd9e2df83666e5c1c1347bef58ce919cd8efc3ffa38aeea62ce493b/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:2b0074e2f56eb2dacca1689760fd2852a068f85a0547a157b82cb4cafeb6768b", size = 306936, upload-time = "2026-05-19T10:09:37.435Z" }, - { url = "https://files.pythonhosted.org/packages/39/86/b695e16f1180c07f43ea98e73ecd21cf63fa2e1b0c1103739013784d11ae/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:913d02d29c9606643418d9ccfc3b72492ab25a6bf7889934e09a3490f8d3438b", size = 342453, upload-time = "2026-05-19T10:09:39.294Z" }, - { url = "https://files.pythonhosted.org/packages/34/56/55d76614af37fe3f22a3347d1e410d2a15da581997cb2da499a625000bb5/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b15d3ec9b0449c40e85319bdb4caa8b77ab526e74f5532ed94bec15e2f66822c", size = 345606, upload-time = "2026-05-19T10:09:40.727Z" }, - { 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" }, +resolution-markers = [ + "python_full_version < '3.10'", ] - -[[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" }, + { name = "mdurl", marker = "python_full_version < '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, ] [[package]] -name = "jsonref" -version = "1.1.0" +name = "markdown-it-py" +version = "4.0.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" }, +resolution-markers = [ + "python_full_version >= '3.14' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version >= '3.10' and python_full_version < '3.14' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version >= '3.10' and extra == 'group-11-agentex-sdk-pydantic-v1' and extra != 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version >= '3.10' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra != 'group-11-agentex-sdk-pydantic-v2'", ] - -[[package]] -name = "jsonschema" -version = "4.26.0" -source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py" }, + { name = "mdurl", marker = "python_full_version >= '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, ] -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" } +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/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, + { 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 = "jsonschema-specifications" -version = "2025.9.1" +name = "mdurl" +version = "0.1.2" 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" } +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/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" }, + { 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 = "jupyter-core" -version = "5.9.1" +name = "multidict" +version = "6.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "platformdirs" }, - { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, ] -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/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, - { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, - { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, - { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, - { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, - { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, - { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, - { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, - { 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" } +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/a9/63/7bdd4adc330abcca54c85728db2327130e49e52e8c3ce685cec44e0f2e9f/multidict-6.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9f474ad5acda359c8758c8accc22032c6abe6dc87a8be2440d097785e27a9349", size = 77153, upload-time = "2025-10-06T14:48:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bb/b6c35ff175ed1a3142222b78455ee31be71a8396ed3ab5280fbe3ebe4e85/multidict-6.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a9db5a870f780220e931d0002bbfd88fb53aceb6293251e2c839415c1b20e", size = 44993, upload-time = "2025-10-06T14:48:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/e0/1f/064c77877c5fa6df6d346e68075c0f6998547afe952d6471b4c5f6a7345d/multidict-6.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03ca744319864e92721195fa28c7a3b2bc7b686246b35e4078c1e4d0eb5466d3", size = 44607, upload-time = "2025-10-06T14:48:29.581Z" }, + { url = "https://files.pythonhosted.org/packages/04/7a/bf6aa92065dd47f287690000b3d7d332edfccb2277634cadf6a810463c6a/multidict-6.7.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f0e77e3c0008bc9316e662624535b88d360c3a5d3f81e15cf12c139a75250046", size = 241847, upload-time = "2025-10-06T14:48:32.107Z" }, + { url = "https://files.pythonhosted.org/packages/94/39/297a8de920f76eda343e4ce05f3b489f0ab3f9504f2576dfb37b7c08ca08/multidict-6.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08325c9e5367aa379a3496aa9a022fe8837ff22e00b94db256d3a1378c76ab32", size = 242616, upload-time = "2025-10-06T14:48:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/39/3a/d0eee2898cfd9d654aea6cb8c4addc2f9756e9a7e09391cfe55541f917f7/multidict-6.7.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e2862408c99f84aa571ab462d25236ef9cb12a602ea959ba9c9009a54902fc73", size = 222333, upload-time = "2025-10-06T14:48:35.9Z" }, + { url = "https://files.pythonhosted.org/packages/05/48/3b328851193c7a4240815b71eea165b49248867bbb6153a0aee227a0bb47/multidict-6.7.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d72a9a2d885f5c208b0cb91ff2ed43636bb7e345ec839ff64708e04f69a13cc", size = 253239, upload-time = "2025-10-06T14:48:37.302Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ca/0706a98c8d126a89245413225ca4a3fefc8435014de309cf8b30acb68841/multidict-6.7.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:478cc36476687bac1514d651cbbaa94b86b0732fb6855c60c673794c7dd2da62", size = 251618, upload-time = "2025-10-06T14:48:38.963Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/9c7992f245554d8b173f6f0a048ad24b3e645d883f096857ec2c0822b8bd/multidict-6.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6843b28b0364dc605f21481c90fadb5f60d9123b442eb8a726bb74feef588a84", size = 241655, upload-time = "2025-10-06T14:48:40.312Z" }, + { url = "https://files.pythonhosted.org/packages/31/79/26a85991ae67efd1c0b1fc2e0c275b8a6aceeb155a68861f63f87a798f16/multidict-6.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23bfeee5316266e5ee2d625df2d2c602b829435fc3a235c2ba2131495706e4a0", size = 239245, upload-time = "2025-10-06T14:48:41.848Z" }, + { url = "https://files.pythonhosted.org/packages/14/1e/75fa96394478930b79d0302eaf9a6c69f34005a1a5251ac8b9c336486ec9/multidict-6.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:680878b9f3d45c31e1f730eef731f9b0bc1da456155688c6745ee84eb818e90e", size = 233523, upload-time = "2025-10-06T14:48:43.749Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5e/085544cb9f9c4ad2b5d97467c15f856df8d9bac410cffd5c43991a5d878b/multidict-6.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:eb866162ef2f45063acc7a53a88ef6fe8bf121d45c30ea3c9cd87ce7e191a8d4", size = 243129, upload-time = "2025-10-06T14:48:45.225Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c3/e9d9e2f20c9474e7a8fcef28f863c5cbd29bb5adce6b70cebe8bdad0039d/multidict-6.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:df0e3bf7993bdbeca5ac25aa859cf40d39019e015c9c91809ba7093967f7a648", size = 248999, upload-time = "2025-10-06T14:48:46.703Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3f/df171b6efa3239ae33b97b887e42671cd1d94d460614bfb2c30ffdab3b95/multidict-6.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:661709cdcd919a2ece2234f9bae7174e5220c80b034585d7d8a755632d3e2111", size = 243711, upload-time = "2025-10-06T14:48:48.146Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2f/9b5564888c4e14b9af64c54acf149263721a283aaf4aa0ae89b091d5d8c1/multidict-6.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:096f52730c3fb8ed419db2d44391932b63891b2c5ed14850a7e215c0ba9ade36", size = 237504, upload-time = "2025-10-06T14:48:49.447Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3a/0bd6ca0f7d96d790542d591c8c3354c1e1b6bfd2024d4d92dc3d87485ec7/multidict-6.7.0-cp310-cp310-win32.whl", hash = "sha256:afa8a2978ec65d2336305550535c9c4ff50ee527914328c8677b3973ade52b85", size = 41422, upload-time = "2025-10-06T14:48:50.789Z" }, + { url = "https://files.pythonhosted.org/packages/00/35/f6a637ea2c75f0d3b7c7d41b1189189acff0d9deeb8b8f35536bb30f5e33/multidict-6.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:b15b3afff74f707b9275d5ba6a91ae8f6429c3ffb29bbfd216b0b375a56f13d7", size = 46050, upload-time = "2025-10-06T14:48:51.938Z" }, + { url = "https://files.pythonhosted.org/packages/e7/b8/f7bf8329b39893d02d9d95cf610c75885d12fc0f402b1c894e1c8e01c916/multidict-6.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:4b73189894398d59131a66ff157837b1fafea9974be486d036bb3d32331fdbf0", size = 43153, upload-time = "2025-10-06T14:48:53.146Z" }, { url = "https://files.pythonhosted.org/packages/34/9e/5c727587644d67b2ed479041e4b1c58e30afc011e3d45d25bbe35781217c/multidict-6.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4d409aa42a94c0b3fa617708ef5276dfe81012ba6753a0370fcc9d0195d0a1fc", size = 76604, upload-time = "2025-10-06T14:48:54.277Z" }, { url = "https://files.pythonhosted.org/packages/17/e4/67b5c27bd17c085a5ea8f1ec05b8a3e5cba0ca734bfcad5560fb129e70ca/multidict-6.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14c9e076eede3b54c636f8ce1c9c252b5f057c62131211f0ceeec273810c9721", size = 44715, upload-time = "2025-10-06T14:48:55.445Z" }, { url = "https://files.pythonhosted.org/packages/4d/e1/866a5d77be6ea435711bef2a4291eed11032679b6b28b56b4776ab06ba3e/multidict-6.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c09703000a9d0fa3c3404b27041e574cc7f4df4c6563873246d0e11812a94b6", size = 44332, upload-time = "2025-10-06T14:48:56.706Z" }, @@ -1764,6 +770,24 @@ wheels = [ { 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/90/d7/4cf84257902265c4250769ac49f4eaab81c182ee9aff8bf59d2714dbb174/multidict-6.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:363eb68a0a59bd2303216d2346e6c441ba10d36d1f9969fcb6f1ba700de7bb5c", size = 77073, upload-time = "2025-10-06T14:51:57.386Z" }, + { url = "https://files.pythonhosted.org/packages/6d/51/194e999630a656e76c2965a1590d12faa5cd528170f2abaa04423e09fe8d/multidict-6.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d874eb056410ca05fed180b6642e680373688efafc7f077b2a2f61811e873a40", size = 44928, upload-time = "2025-10-06T14:51:58.791Z" }, + { url = "https://files.pythonhosted.org/packages/e5/6b/2a195373c33068c9158e0941d0b46cfcc9c1d894ca2eb137d1128081dff0/multidict-6.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b55d5497b51afdfde55925e04a022f1de14d4f4f25cdfd4f5d9b0aa96166851", size = 44581, upload-time = "2025-10-06T14:52:00.174Z" }, + { url = "https://files.pythonhosted.org/packages/69/7b/7f4f2e644b6978bf011a5fd9a5ebb7c21de3f38523b1f7897d36a1ac1311/multidict-6.7.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f8e5c0031b90ca9ce555e2e8fd5c3b02a25f14989cbc310701823832c99eb687", size = 239901, upload-time = "2025-10-06T14:52:02.416Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b5/952c72786710a031aa204a9adf7db66d7f97a2c6573889d58b9e60fe6702/multidict-6.7.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cf41880c991716f3c7cec48e2f19ae4045fc9db5fc9cff27347ada24d710bb5", size = 240534, upload-time = "2025-10-06T14:52:04.105Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ef/109fe1f2471e4c458c74242c7e4a833f2d9fc8a6813cd7ee345b0bad18f9/multidict-6.7.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cfc12a8630a29d601f48d47787bd7eb730e475e83edb5d6c5084317463373eb", size = 219545, upload-time = "2025-10-06T14:52:06.208Z" }, + { url = "https://files.pythonhosted.org/packages/42/bd/327d91288114967f9fe90dc53de70aa3fec1b9073e46aa32c4828f771a87/multidict-6.7.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3996b50c3237c4aec17459217c1e7bbdead9a22a0fcd3c365564fbd16439dde6", size = 251187, upload-time = "2025-10-06T14:52:08.049Z" }, + { url = "https://files.pythonhosted.org/packages/f4/13/a8b078ebbaceb7819fd28cd004413c33b98f1b70d542a62e6a00b74fb09f/multidict-6.7.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7f5170993a0dd3ab871c74f45c0a21a4e2c37a2f2b01b5f722a2ad9c6650469e", size = 249379, upload-time = "2025-10-06T14:52:09.831Z" }, + { url = "https://files.pythonhosted.org/packages/e3/6d/ab12e1246be4d65d1f55de1e6f6aaa9b8120eddcfdd1d290439c7833d5ce/multidict-6.7.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ec81878ddf0e98817def1e77d4f50dae5ef5b0e4fe796fae3bd674304172416e", size = 239241, upload-time = "2025-10-06T14:52:11.561Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/079a93625208c173b8fa756396814397c0fd9fee61ef87b75a748820b86e/multidict-6.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9281bf5b34f59afbc6b1e477a372e9526b66ca446f4bf62592839c195a718b32", size = 237418, upload-time = "2025-10-06T14:52:13.671Z" }, + { url = "https://files.pythonhosted.org/packages/c9/29/03777c2212274aa9440918d604dc9d6af0e6b4558c611c32c3dcf1a13870/multidict-6.7.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:68af405971779d8b37198726f2b6fe3955db846fee42db7a4286fc542203934c", size = 232987, upload-time = "2025-10-06T14:52:15.708Z" }, + { url = "https://files.pythonhosted.org/packages/d9/00/11188b68d85a84e8050ee34724d6ded19ad03975caebe0c8dcb2829b37bf/multidict-6.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3ba3ef510467abb0667421a286dc906e30eb08569365f5cdb131d7aff7c2dd84", size = 240985, upload-time = "2025-10-06T14:52:17.317Z" }, + { url = "https://files.pythonhosted.org/packages/df/0c/12eef6aeda21859c6cdf7d75bd5516d83be3efe3d8cc45fd1a3037f5b9dc/multidict-6.7.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b61189b29081a20c7e4e0b49b44d5d44bb0dc92be3c6d06a11cc043f81bf9329", size = 246855, upload-time = "2025-10-06T14:52:19.096Z" }, + { url = "https://files.pythonhosted.org/packages/69/f6/076120fd8bb3975f09228e288e08bff6b9f1bfd5166397c7ba284f622ab2/multidict-6.7.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fb287618b9c7aa3bf8d825f02d9201b2f13078a5ed3b293c8f4d953917d84d5e", size = 241804, upload-time = "2025-10-06T14:52:21.166Z" }, + { url = "https://files.pythonhosted.org/packages/5f/51/41bb950c81437b88a93e6ddfca1d8763569ae861e638442838c4375f7497/multidict-6.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:521f33e377ff64b96c4c556b81c55d0cfffb96a11c194fd0c3f1e56f3d8dd5a4", size = 235321, upload-time = "2025-10-06T14:52:23.208Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cf/5bbd31f055199d56c1f6b04bbadad3ccb24e6d5d4db75db774fc6d6674b8/multidict-6.7.0-cp39-cp39-win32.whl", hash = "sha256:ce8fdc2dca699f8dbf055a61d73eaa10482569ad20ee3c36ef9641f69afa8c91", size = 41435, upload-time = "2025-10-06T14:52:24.735Z" }, + { url = "https://files.pythonhosted.org/packages/af/01/547ffe9c2faec91c26965c152f3fea6cff068b6037401f61d310cc861ff4/multidict-6.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:7e73299c99939f089dd9b2120a04a516b95cdf8c1cd2b18c53ebf0de80b1f18f", size = 46193, upload-time = "2025-10-06T14:52:26.101Z" }, + { url = "https://files.pythonhosted.org/packages/27/77/cfa5461d1d2651d6fc24216c92b4a21d4e385a41c46e0d9f3b070675167b/multidict-6.7.0-cp39-cp39-win_arm64.whl", hash = "sha256:6bdce131e14b04fd34a809b6380dbfd826065c3e2fe8a50dbae659fa0c390546", size = 43118, upload-time = "2025-10-06T14:52:27.876Z" }, { 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" }, ] @@ -1774,10 +798,17 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, { 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/6a/31/e762baa3b73905c856d45ab77b4af850e8159dffffd86a52879539a08c6b/mypy-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8e08de6138043108b3b18f09d3f817a4783912e48828ab397ecf183135d84d6", size = 10998313, upload-time = "2025-07-14T20:33:24.519Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c1/25b2f0d46fb7e0b5e2bee61ec3a47fe13eff9e3c2f2234f144858bbe6485/mypy-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ce4a17920ec144647d448fc43725b5873548b1aae6c603225626747ededf582d", size = 10128922, upload-time = "2025-07-14T20:34:06.414Z" }, + { url = "https://files.pythonhosted.org/packages/02/78/6d646603a57aa8a2886df1b8881fe777ea60f28098790c1089230cd9c61d/mypy-1.17.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ff25d151cc057fdddb1cb1881ef36e9c41fa2a5e78d8dd71bee6e4dcd2bc05b", size = 11913524, upload-time = "2025-07-14T20:33:19.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/19/dae6c55e87ee426fb76980f7e78484450cad1c01c55a1dc4e91c930bea01/mypy-1.17.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93468cf29aa9a132bceb103bd8475f78cacde2b1b9a94fd978d50d4bdf616c9a", size = 12650527, upload-time = "2025-07-14T20:32:44.095Z" }, + { url = "https://files.pythonhosted.org/packages/86/e1/f916845a235235a6c1e4d4d065a3930113767001d491b8b2e1b61ca56647/mypy-1.17.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:98189382b310f16343151f65dd7e6867386d3e35f7878c45cfa11383d175d91f", size = 12897284, upload-time = "2025-07-14T20:33:38.168Z" }, + { url = "https://files.pythonhosted.org/packages/ae/dc/414760708a4ea1b096bd214d26a24e30ac5e917ef293bc33cdb6fe22d2da/mypy-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:c004135a300ab06a045c1c0d8e3f10215e71d7b4f5bb9a42ab80236364429937", size = 9506493, upload-time = "2025-07-14T20:34:01.093Z" }, { url = "https://files.pythonhosted.org/packages/d4/24/82efb502b0b0f661c49aa21cfe3e1999ddf64bf5500fc03b5a1536a39d39/mypy-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9d4fe5c72fd262d9c2c91c1117d16aac555e05f5beb2bae6a755274c6eec42be", size = 10914150, upload-time = "2025-07-14T20:31:51.985Z" }, { url = "https://files.pythonhosted.org/packages/03/96/8ef9a6ff8cedadff4400e2254689ca1dc4b420b92c55255b44573de10c54/mypy-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d96b196e5c16f41b4f7736840e8455958e832871990c7ba26bf58175e357ed61", size = 10039845, upload-time = "2025-07-14T20:32:30.527Z" }, { url = "https://files.pythonhosted.org/packages/df/32/7ce359a56be779d38021d07941cfbb099b41411d72d827230a36203dbb81/mypy-1.17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73a0ff2dd10337ceb521c080d4147755ee302dcde6e1a913babd59473904615f", size = 11837246, upload-time = "2025-07-14T20:32:01.28Z" }, @@ -1796,6 +827,12 @@ wheels = [ { 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/9f/a0/6263dd11941231f688f0a8f2faf90ceac1dc243d148d314a089d2fe25108/mypy-1.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:63e751f1b5ab51d6f3d219fe3a2fe4523eaa387d854ad06906c63883fde5b1ab", size = 10988185, upload-time = "2025-07-14T20:33:04.797Z" }, + { url = "https://files.pythonhosted.org/packages/02/13/b8f16d6b0dc80277129559c8e7dbc9011241a0da8f60d031edb0e6e9ac8f/mypy-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f7fb09d05e0f1c329a36dcd30e27564a3555717cde87301fae4fb542402ddfad", size = 10120169, upload-time = "2025-07-14T20:32:38.84Z" }, + { url = "https://files.pythonhosted.org/packages/14/ef/978ba79df0d65af680e20d43121363cf643eb79b04bf3880d01fc8afeb6f/mypy-1.17.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b72c34ce05ac3a1361ae2ebb50757fb6e3624032d91488d93544e9f82db0ed6c", size = 11918121, upload-time = "2025-07-14T20:33:52.328Z" }, + { url = "https://files.pythonhosted.org/packages/f4/10/55ef70b104151a0d8280474f05268ff0a2a79be8d788d5e647257d121309/mypy-1.17.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434ad499ad8dde8b2f6391ddfa982f41cb07ccda8e3c67781b1bfd4e5f9450a8", size = 12648821, upload-time = "2025-07-14T20:32:59.631Z" }, + { url = "https://files.pythonhosted.org/packages/26/8c/7781fcd2e1eef48fbedd3a422c21fe300a8e03ed5be2eb4bd10246a77f4e/mypy-1.17.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f105f61a5eff52e137fd73bee32958b2add9d9f0a856f17314018646af838e97", size = 12896955, upload-time = "2025-07-14T20:32:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/03ac759dabe86e98ca7b6681f114f90ee03f3ff8365a57049d311bd4a4e3/mypy-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:ba06254a5a22729853209550d80f94e28690d5530c661f9416a68ac097b13fc4", size = 9512957, upload-time = "2025-07-14T20:33:28.619Z" }, { 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" }, ] @@ -1808,54 +845,6 @@ 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" @@ -1865,208 +854,6 @@ 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/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" }, - { url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" }, - { url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" }, - { url = "https://files.pythonhosted.org/packages/ea/76/f11311285324a40aab1e3031385c50b635a7cd0734fdaf60c7e89a696f60/orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c", size = 127988, upload-time = "2026-05-06T15:09:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/9e/85/0ef63bcf1337f44031ce9b91b1919563f62a37527b3ea4368bb15a22e5d7/orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd", size = 135188, upload-time = "2026-05-06T15:09:40.957Z" }, - { url = "https://files.pythonhosted.org/packages/05/94/b0d27090ea8a2095db3c2bd1b1c96f96f19bbb494d7fef33130e846e613d/orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62", size = 145937, upload-time = "2026-05-06T15:09:42.249Z" }, - { url = "https://files.pythonhosted.org/packages/09/eb/75d50c29c05b8054013e221e598820a365c8e64065312e75e202ed880709/orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877", size = 132758, upload-time = "2026-05-06T15:09:43.945Z" }, - { url = "https://files.pythonhosted.org/packages/49/bd/360686f39348aa88827cb6fbf7dc606fd41c831a35235e1abf1db8e3a9e6/orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1", size = 133971, upload-time = "2026-05-06T15:09:45.239Z" }, - { url = "https://files.pythonhosted.org/packages/0e/30/3178eb16f3221aeef068b6f1f1ebe05f656ea5c6dffe9f6c917329fe17a3/orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd", size = 141685, upload-time = "2026-05-06T15:09:46.858Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f1/ff2f19ed0225f9680fafa42febca3570dd59444ebf190980738d376214c2/orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff", size = 415167, upload-time = "2026-05-06T15:09:48.312Z" }, - { url = "https://files.pythonhosted.org/packages/9b/61/863bddf0da6e9e586765414debd54b4e58db05f560902b6d00658cb88636/orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980", size = 147913, upload-time = "2026-05-06T15:09:49.733Z" }, - { url = "https://files.pythonhosted.org/packages/b6/8a/4081492586d75b073d60c5271a8d0f05a0955cabf1e34c8473f6fcd84235/orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2", size = 136959, upload-time = "2026-05-06T15:09:51.311Z" }, - { url = "https://files.pythonhosted.org/packages/0d/bd/70b6ab193594d7abb875320c0a7c8335e846f28968c432c31042409c3c8d/orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180", size = 131533, upload-time = "2026-05-06T15:09:52.637Z" }, - { url = "https://files.pythonhosted.org/packages/3f/17/1a1a228183d62d1b77e2c30d210f47dd4768b310ebe1607c63e3c0e3a71e/orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02", size = 127106, upload-time = "2026-05-06T15:09:54.204Z" }, - { url = "https://files.pythonhosted.org/packages/b8/95/285de5fa296d09681ee9c546cd4a8aeb773b701cf343dc125994f4d52953/orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697", size = 126848, upload-time = "2026-05-06T15:09:55.551Z" }, - { 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/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266, upload-time = "2026-01-18T20:55:59.876Z" }, - { url = "https://files.pythonhosted.org/packages/0d/24/29fc13044ecb7c153523ae0a1972269fcd613650d1fa1a9cec1044c6b666/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a", size = 203035, upload-time = "2026-01-18T20:55:30.59Z" }, - { url = "https://files.pythonhosted.org/packages/ad/c2/00169fb25dd8f9213f5e8a549dfb73e4d592009ebc85fbbcd3e1dcac575b/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5", size = 210539, upload-time = "2026-01-18T20:55:48.569Z" }, - { url = "https://files.pythonhosted.org/packages/1b/33/543627f323ff3c73091f51d6a20db28a1a33531af30873ea90c5ac95a9b5/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43013a3f3e2e902e1d05e72c0f1aeb5bedbb8e09240b51e26792a3c89267e181", size = 212401, upload-time = "2026-01-18T20:56:10.101Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5d/f70e2c3da414f46186659d24745483757bcc9adccb481a6eb93e2b729301/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7c8b1667a72cbba74f0ae7ecf3105a5e01304620ed14528b2cb4320679d2869b", size = 387082, upload-time = "2026-01-18T20:56:12.047Z" }, - { url = "https://files.pythonhosted.org/packages/c0/d6/06e8dc920c7903e051f30934d874d4afccc9bb1c09dcaf0bc03a7de4b343/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:df6961442140193e517303d0b5d7bc2e20e69a879c2d774316125350c4a76b92", size = 482346, upload-time = "2026-01-18T20:56:05.152Z" }, - { url = "https://files.pythonhosted.org/packages/66/c4/f337ac0905eed9c393ef990c54565cd33644918e0a8031fe48c098c71dbf/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6a4c34ddef109647c769d69be65fa1de7a6022b02ad45546a69b3216573eb4a", size = 425181, upload-time = "2026-01-18T20:55:37.83Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/6d5758fabef3babdf4bbbc453738cc7de9cd3334e4c38dd5737e27b85653/ormsgpack-1.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:73670ed0375ecc303858e3613f407628dd1fca18fe6ac57b7b7ce66cc7bb006c", size = 117182, upload-time = "2026-01-18T20:55:31.472Z" }, - { url = "https://files.pythonhosted.org/packages/c4/57/17a15549233c37e7fd054c48fe9207492e06b026dbd872b826a0b5f833b6/ormsgpack-1.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2be829954434e33601ae5da328cccce3266b098927ca7a30246a0baec2ce7bd", size = 111464, upload-time = "2026-01-18T20:55:38.811Z" }, - { 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" @@ -2076,15 +863,6 @@ 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" @@ -2094,27 +872,6 @@ 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" @@ -2124,24 +881,27 @@ 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/3c/0e/934b541323035566a9af292dba85a195f7b78179114f2c6ebb24551118a9/propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db", size = 79534, upload-time = "2025-10-08T19:46:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/db0d03d96726d995dc7171286c6ba9d8d14251f37433890f88368951a44e/propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8", size = 45526, upload-time = "2025-10-08T19:46:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c3/82728404aea669e1600f304f2609cde9e665c18df5a11cdd57ed73c1dceb/propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925", size = 47263, upload-time = "2025-10-08T19:46:05.405Z" }, + { url = "https://files.pythonhosted.org/packages/df/1b/39313ddad2bf9187a1432654c38249bab4562ef535ef07f5eb6eb04d0b1b/propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21", size = 201012, upload-time = "2025-10-08T19:46:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/5b/01/f1d0b57d136f294a142acf97f4ed58c8e5b974c21e543000968357115011/propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5", size = 209491, upload-time = "2025-10-08T19:46:08.909Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c8/038d909c61c5bb039070b3fb02ad5cccdb1dde0d714792e251cdb17c9c05/propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db", size = 215319, upload-time = "2025-10-08T19:46:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/08/57/8c87e93142b2c1fa2408e45695205a7ba05fb5db458c0bf5c06ba0e09ea6/propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7", size = 196856, upload-time = "2025-10-08T19:46:12.003Z" }, + { url = "https://files.pythonhosted.org/packages/42/df/5615fec76aa561987a534759b3686008a288e73107faa49a8ae5795a9f7a/propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4", size = 193241, upload-time = "2025-10-08T19:46:13.495Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/62949eb3a7a54afe8327011c90aca7e03547787a88fb8bd9726806482fea/propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60", size = 190552, upload-time = "2025-10-08T19:46:14.938Z" }, + { url = "https://files.pythonhosted.org/packages/30/ee/ab4d727dd70806e5b4de96a798ae7ac6e4d42516f030ee60522474b6b332/propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f", size = 200113, upload-time = "2025-10-08T19:46:16.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0b/38b46208e6711b016aa8966a3ac793eee0d05c7159d8342aa27fc0bc365e/propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900", size = 200778, upload-time = "2025-10-08T19:46:18.023Z" }, + { url = "https://files.pythonhosted.org/packages/cf/81/5abec54355ed344476bee711e9f04815d4b00a311ab0535599204eecc257/propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c", size = 193047, upload-time = "2025-10-08T19:46:19.449Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b6/1f237c04e32063cb034acd5f6ef34ef3a394f75502e72703545631ab1ef6/propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb", size = 38093, upload-time = "2025-10-08T19:46:20.643Z" }, + { url = "https://files.pythonhosted.org/packages/a6/67/354aac4e0603a15f76439caf0427781bcd6797f370377f75a642133bc954/propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37", size = 41638, upload-time = "2025-10-08T19:46:21.935Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e1/74e55b9fd1a4c209ff1a9a824bf6c8b3d1fc5a1ac3eabe23462637466785/propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581", size = 38229, upload-time = "2025-10-08T19:46:23.368Z" }, { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, @@ -2232,121 +992,114 @@ wheels = [ { 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/9b/01/0ebaec9003f5d619a7475165961f8e3083cf8644d704b60395df3601632d/propcache-0.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3d233076ccf9e450c8b3bc6720af226b898ef5d051a2d145f7d765e6e9f9bcff", size = 80277, upload-time = "2025-10-08T19:48:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/34/58/04af97ac586b4ef6b9026c3fd36ee7798b737a832f5d3440a4280dcebd3a/propcache-0.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:357f5bb5c377a82e105e44bd3d52ba22b616f7b9773714bff93573988ef0a5fb", size = 45865, upload-time = "2025-10-08T19:48:37.859Z" }, + { url = "https://files.pythonhosted.org/packages/7c/19/b65d98ae21384518b291d9939e24a8aeac4fdb5101b732576f8f7540e834/propcache-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cbc3b6dfc728105b2a57c06791eb07a94229202ea75c59db644d7d496b698cac", size = 47636, upload-time = "2025-10-08T19:48:39.038Z" }, + { url = "https://files.pythonhosted.org/packages/b3/0f/317048c6d91c356c7154dca5af019e6effeb7ee15fa6a6db327cc19e12b4/propcache-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:182b51b421f0501952d938dc0b0eb45246a5b5153c50d42b495ad5fb7517c888", size = 201126, upload-time = "2025-10-08T19:48:40.774Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/0b2a7a5a6ee83292b4b997dbd80549d8ce7d40b6397c1646c0d9495f5a85/propcache-0.4.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b536b39c5199b96fc6245eb5fb796c497381d3942f169e44e8e392b29c9ebcc", size = 209837, upload-time = "2025-10-08T19:48:42.167Z" }, + { url = "https://files.pythonhosted.org/packages/a5/92/c699ac495a6698df6e497fc2de27af4b6ace10d8e76528357ce153722e45/propcache-0.4.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db65d2af507bbfbdcedb254a11149f894169d90488dd3e7190f7cdcb2d6cd57a", size = 215578, upload-time = "2025-10-08T19:48:43.56Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ee/14de81c5eb02c0ee4f500b4e39c4e1bd0677c06e72379e6ab18923c773fc/propcache-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd2dbc472da1f772a4dae4fa24be938a6c544671a912e30529984dd80400cd88", size = 197187, upload-time = "2025-10-08T19:48:45.309Z" }, + { url = "https://files.pythonhosted.org/packages/1d/94/48dce9aaa6d8dd5a0859bad75158ec522546d4ac23f8e2f05fac469477dd/propcache-0.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:daede9cd44e0f8bdd9e6cc9a607fc81feb80fae7a5fc6cecaff0e0bb32e42d00", size = 193478, upload-time = "2025-10-08T19:48:47.743Z" }, + { url = "https://files.pythonhosted.org/packages/60/b5/0516b563e801e1ace212afde869a0596a0d7115eec0b12d296d75633fb29/propcache-0.4.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:71b749281b816793678ae7f3d0d84bd36e694953822eaad408d682efc5ca18e0", size = 190650, upload-time = "2025-10-08T19:48:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/24/89/e0f7d4a5978cd56f8cd67735f74052f257dc471ec901694e430f0d1572fe/propcache-0.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:0002004213ee1f36cfb3f9a42b5066100c44276b9b72b4e1504cddd3d692e86e", size = 200251, upload-time = "2025-10-08T19:48:51.4Z" }, + { url = "https://files.pythonhosted.org/packages/06/7d/a1fac863d473876ed4406c914f2e14aa82d2f10dd207c9e16fc383cc5a24/propcache-0.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fe49d0a85038f36ba9e3ffafa1103e61170b28e95b16622e11be0a0ea07c6781", size = 200919, upload-time = "2025-10-08T19:48:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/c3/4e/f86a256ff24944cf5743e4e6c6994e3526f6acfcfb55e21694c2424f758c/propcache-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:99d43339c83aaf4d32bda60928231848eee470c6bda8d02599cc4cebe872d183", size = 193211, upload-time = "2025-10-08T19:48:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/6e/3f/3fbad5f4356b068f1b047d300a6ff2c66614d7030f078cd50be3fec04228/propcache-0.4.1-cp39-cp39-win32.whl", hash = "sha256:a129e76735bc792794d5177069691c3217898b9f5cee2b2661471e52ffe13f19", size = 38314, upload-time = "2025-10-08T19:48:56.792Z" }, + { url = "https://files.pythonhosted.org/packages/a4/45/d78d136c3a3d215677abb886785aae744da2c3005bcb99e58640c56529b1/propcache-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:948dab269721ae9a87fd16c514a0a2c2a1bdb23a9a61b969b0f9d9ee2968546f", size = 41912, upload-time = "2025-10-08T19:48:57.995Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2a/b0632941f25139f4e58450b307242951f7c2717a5704977c6d5323a800af/propcache-0.4.1-cp39-cp39-win_arm64.whl", hash = "sha256:5fd37c406dd6dc85aa743e214cef35dc54bbdd1419baac4f6ae5e5b1a2976938", size = 38450, upload-time = "2025-10-08T19:48:59.349Z" }, { 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" +name = "pydantic" +version = "1.10.26" 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" }, +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] +dependencies = [ + { name = "typing-extensions", marker = "extra == 'group-11-agentex-sdk-pydantic-v1'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/da/fd89f987a376c807cd81ea0eff4589aade783bbb702637b4734ef2c743a2/pydantic-1.10.26.tar.gz", hash = "sha256:8c6aa39b494c5af092e690127c283d84f363ac36017106a9e66cb33a22ac412e", size = 357906, upload-time = "2025-12-18T15:47:46.557Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/08/2587a6d4314e7539eec84acd062cb7b037638edb57a0335d20e4c5b8878c/pydantic-1.10.26-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f7ae36fa0ecef8d39884120f212e16c06bb096a38f523421278e2f39c1784546", size = 2444588, upload-time = "2025-12-18T15:46:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/47/e6/10df5f08c105bcbb4adbee7d1108ff4b347702b110fed058f6a03f1c6b73/pydantic-1.10.26-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d95a76cf503f0f72ed7812a91de948440b2bf564269975738a4751e4fadeb572", size = 2255972, upload-time = "2025-12-18T15:46:31.72Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/fdb961e7adc2c31f394feba6f560ef2c74c446f0285e2c2eb87d2b7206c7/pydantic-1.10.26-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a943ce8e00ad708ed06a1d9df5b4fd28f5635a003b82a4908ece6f24c0b18464", size = 2857175, upload-time = "2025-12-18T15:46:34Z" }, + { url = "https://files.pythonhosted.org/packages/8f/6c/f21e27dda475d4c562bd01b5874284dd3180f336c1e669413b743ca8b278/pydantic-1.10.26-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:465ad8edb29b15c10b779b16431fe8e77c380098badf6db367b7a1d3e572cf53", size = 2947001, upload-time = "2025-12-18T15:46:35.922Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f6/27ea206232cbb6ec24dc4e4e8888a9a734f96a1eaf13504be4b30ef26aa7/pydantic-1.10.26-cp310-cp310-win_amd64.whl", hash = "sha256:80e6be6272839c8a7641d26ad569ab77772809dd78f91d0068dc0fc97f071945", size = 2066217, upload-time = "2025-12-18T15:46:37.614Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c1/d521e64c8130e1ad9d22c270bed3fabcc0940c9539b076b639c88fd32a8d/pydantic-1.10.26-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:116233e53889bcc536f617e38c1b8337d7fa9c280f0fd7a4045947515a785637", size = 2428347, upload-time = "2025-12-18T15:46:39.41Z" }, + { url = "https://files.pythonhosted.org/packages/2c/08/f4b804a00c16e3ea994cb640a7c25c579b4f1fa674cde6a19fa0dfb0ae4f/pydantic-1.10.26-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3cfdd361addb6eb64ccd26ac356ad6514cee06a61ab26b27e16b5ed53108f77", size = 2212605, upload-time = "2025-12-18T15:46:41.006Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/0df4b9efef29bbc5e39f247fcba99060d15946b4463d82a5589cf7923d71/pydantic-1.10.26-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e4451951a9a93bf9a90576f3e25240b47ee49ab5236adccb8eff6ac943adf0f", size = 2753560, upload-time = "2025-12-18T15:46:43.215Z" }, + { url = "https://files.pythonhosted.org/packages/68/66/6ab6c1d3a116d05d2508fce64f96e35242938fac07544d611e11d0d363a0/pydantic-1.10.26-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9858ed44c6bea5f29ffe95308db9e62060791c877766c67dd5f55d072c8612b5", size = 2859235, upload-time = "2025-12-18T15:46:45.112Z" }, + { url = "https://files.pythonhosted.org/packages/61/4e/f1676bb0fcdf6ed2ce4670d7d1fc1d6c3a06d84497644acfbe02649503f1/pydantic-1.10.26-cp311-cp311-win_amd64.whl", hash = "sha256:ac1089f723e2106ebde434377d31239e00870a7563245072968e5af5cc4d33df", size = 2066646, upload-time = "2025-12-18T15:46:46.816Z" }, + { url = "https://files.pythonhosted.org/packages/02/6c/cd97a5a776c4515e6ee2ae81c2f2c5be51376dda6c31f965d7746ce0019f/pydantic-1.10.26-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:468d5b9cacfcaadc76ed0a4645354ab6f263ec01a63fb6d05630ea1df6ae453f", size = 2433795, upload-time = "2025-12-18T15:46:49.321Z" }, + { url = "https://files.pythonhosted.org/packages/47/12/de20affa30dcef728fcf9cc98e13ff4438c7a630de8d2f90eb38eba0891c/pydantic-1.10.26-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2c1b0b914be31671000ca25cf7ea17fcaaa68cfeadf6924529c5c5aa24b7ab1f", size = 2227387, upload-time = "2025-12-18T15:46:50.877Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1d/9d65dcc5b8c17ba590f1f9f486e9306346831902318b7ee93f63516f4003/pydantic-1.10.26-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15b13b9f8ba8867095769e1156e0d7fbafa1f65b898dd40fd1c02e34430973cb", size = 2629594, upload-time = "2025-12-18T15:46:53.42Z" }, + { url = "https://files.pythonhosted.org/packages/3f/76/acb41409356789e23e1a7ef58f93821410c96409183ce314ddb58d97f23e/pydantic-1.10.26-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad7025ca324ae263d4313998e25078dcaec5f9ed0392c06dedb57e053cc8086b", size = 2745305, upload-time = "2025-12-18T15:46:55.987Z" }, + { url = "https://files.pythonhosted.org/packages/22/72/a98c0c5e527a66057d969fedd61675223c7975ade61acebbca9f1abd6dc0/pydantic-1.10.26-cp312-cp312-win_amd64.whl", hash = "sha256:4482b299874dabb88a6c3759e3d85c6557c407c3b586891f7d808d8a38b66b9c", size = 1937647, upload-time = "2025-12-18T15:46:57.905Z" }, + { url = "https://files.pythonhosted.org/packages/28/b9/17a5a5a421c23ac27486b977724a42c9d5f8b7f0f4aab054251066223900/pydantic-1.10.26-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1ae7913bb40a96c87e3d3f6fe4e918ef53bf181583de4e71824360a9b11aef1c", size = 2494599, upload-time = "2025-12-18T15:47:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8e/6e3bd4241076cf227b443d7577245dd5d181ecf40b3182fcb908bc8c197d/pydantic-1.10.26-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8154c13f58d4de5d3a856bb6c909c7370f41fb876a5952a503af6b975265f4ba", size = 2254391, upload-time = "2025-12-18T15:47:02.268Z" }, + { url = "https://files.pythonhosted.org/packages/a8/30/a1c4092eda2145ecbead6c92db489b223e101e1ba0da82576d0cf73dd422/pydantic-1.10.26-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f8af0507bf6118b054a9765fb2e402f18a8b70c964f420d95b525eb711122d62", size = 2609445, upload-time = "2025-12-18T15:47:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/3a/2a/0491f1729ee4b7b6bc859ec22f69752f0c09bee1b66ac6f5f701136f34c3/pydantic-1.10.26-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dcb5a7318fb43189fde6af6f21ac7149c4bcbcfffc54bc87b5becddc46084847", size = 2732124, upload-time = "2025-12-18T15:47:07.464Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/b59f3b2f84e1df2b04ae768a1bb04d9f0288ff71b67cdcbb07683757b2c0/pydantic-1.10.26-cp313-cp313-win_amd64.whl", hash = "sha256:71cde228bc0600cf8619f0ee62db050d1880dcc477eba0e90b23011b4ee0f314", size = 1939888, upload-time = "2025-12-18T15:47:09.618Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8b/0c3dc02d4b97790b0f199bf933f677c14e7be4a8d21307c5f2daae06aa41/pydantic-1.10.26-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6b40730cc81d53d515dc0b8bb5c9b43fadb9bed46de4a3c03bd95e8571616dba", size = 2502689, upload-time = "2025-12-18T15:47:12.308Z" }, + { url = "https://files.pythonhosted.org/packages/d4/9d/d31aeea45542b2ae4b09ecba92b88aaba696b801c31919811aa979a1242d/pydantic-1.10.26-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c3bbb9c0eecdf599e4db9b372fa9cc55be12e80a0d9c6d307950a39050cb0e37", size = 2269494, upload-time = "2025-12-18T15:47:14.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/c1/3a4d069593283ca4dd0006039ba33644e21e432cddc09da706ac50441610/pydantic-1.10.26-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc2e3fe7bc4993626ef6b6fa855defafa1d6f8996aa1caef2deb83c5ac4d043a", size = 2620047, upload-time = "2025-12-18T15:47:17.089Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0e/340c3d29197d99c15ab04093d43bb9c9d0fd17c2a34b80cb9d36ed732b09/pydantic-1.10.26-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:36d9e46b588aaeb1dcd2409fa4c467fe0b331f3cc9f227b03a7a00643704e962", size = 2747625, upload-time = "2025-12-18T15:47:19.21Z" }, + { url = "https://files.pythonhosted.org/packages/1e/58/f12ab3727339b172c830b32151919456b67787cdfe8808b2568b322fb15c/pydantic-1.10.26-cp314-cp314-win_amd64.whl", hash = "sha256:81ce3c8616d12a7be31b4aadfd3434f78f6b44b75adbfaec2fe1ad4f7f999b8c", size = 1976436, upload-time = "2025-12-18T15:47:21.384Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8a/3a5a6267d5f03617b5c0f1985aa9fdfbafd33a50ef6dadd866a15ed4d123/pydantic-1.10.26-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:502b9d30d18a2dfaf81b7302f6ba0e5853474b1c96212449eb4db912cb604b7d", size = 2457039, upload-time = "2025-12-18T15:47:34.584Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/343ac0db26918a033ac6256c036d72c3b6eb1196b7de622e2e8a94b19079/pydantic-1.10.26-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0d8f6087bf697dec3bf7ffcd7fe8362674f16519f3151789f33cbe8f1d19fc15", size = 2266441, upload-time = "2025-12-18T15:47:36.807Z" }, + { url = "https://files.pythonhosted.org/packages/fc/36/1ab48136578608dba2f2a62e452f3db2083b474d4e49be5749c6ae0c123c/pydantic-1.10.26-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dd40a99c358419910c85e6f5d22f9c56684c25b5e7abc40879b3b4a52f34ae90", size = 2869383, upload-time = "2025-12-18T15:47:38.883Z" }, + { url = "https://files.pythonhosted.org/packages/a2/25/41dbf1bffc31eb242cece8080561a4133eaeb513372dec36a84477a3fb71/pydantic-1.10.26-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ce3293b86ca9f4125df02ff0a70be91bc7946522467cbd98e7f1493f340616ba", size = 2963582, upload-time = "2025-12-18T15:47:40.854Z" }, + { url = "https://files.pythonhosted.org/packages/61/2f/f072ae160a300c85eb9f059915101fd33dacf12d8df08c2b804acb3b95d1/pydantic-1.10.26-cp39-cp39-win_amd64.whl", hash = "sha256:1a4e3062b71ab1d5df339ba12c48f9ed5817c5de6cb92a961dd5c64bb32e7b96", size = 2075530, upload-time = "2025-12-18T15:47:43.181Z" }, + { url = "https://files.pythonhosted.org/packages/1f/98/556e82f00b98486def0b8af85da95e69d2be7e367cf2431408e108bc3095/pydantic-1.10.26-py3-none-any.whl", hash = "sha256:c43ad70dc3ce7787543d563792426a16fd7895e14be4b194b5665e36459dd917", size = 166975, upload-time = "2025-12-18T15:47:44.927Z" }, ] [[package]] name = "pydantic" version = "2.12.5" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version >= '3.10' and python_full_version < '3.14' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version < '3.10' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version >= '3.10' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra != 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version < '3.10' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra != 'group-11-agentex-sdk-pydantic-v2'", +] dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, + { name = "annotated-types", marker = "extra == 'group-11-agentex-sdk-pydantic-v2' or extra != 'group-11-agentex-sdk-pydantic-v1'" }, + { name = "pydantic-core", marker = "extra == 'group-11-agentex-sdk-pydantic-v2' or extra != 'group-11-agentex-sdk-pydantic-v1'" }, + { name = "typing-extensions", marker = "extra == 'group-11-agentex-sdk-pydantic-v2' or extra != 'group-11-agentex-sdk-pydantic-v1'" }, + { name = "typing-inspection", marker = "extra == 'group-11-agentex-sdk-pydantic-v2' or extra != 'group-11-agentex-sdk-pydantic-v1'" }, ] 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" }, + { name = "typing-extensions", marker = "extra == 'group-11-agentex-sdk-pydantic-v2' or extra != 'group-11-agentex-sdk-pydantic-v1'" }, ] 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/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, @@ -2417,6 +1170,19 @@ wheels = [ { 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/54/db/160dffb57ed9a3705c4cbcbff0ac03bdae45f1ca7d58ab74645550df3fbd/pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf", size = 2107999, upload-time = "2025-11-04T13:42:03.885Z" }, + { url = "https://files.pythonhosted.org/packages/a3/7d/88e7de946f60d9263cc84819f32513520b85c0f8322f9b8f6e4afc938383/pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5", size = 1929745, upload-time = "2025-11-04T13:42:06.075Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c2/aef51e5b283780e85e99ff19db0f05842d2d4a8a8cd15e63b0280029b08f/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d", size = 1920220, upload-time = "2025-11-04T13:42:08.457Z" }, + { url = "https://files.pythonhosted.org/packages/c7/97/492ab10f9ac8695cd76b2fdb24e9e61f394051df71594e9bcc891c9f586e/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60", size = 2067296, upload-time = "2025-11-04T13:42:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/ec/23/984149650e5269c59a2a4c41d234a9570adc68ab29981825cfaf4cfad8f4/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82", size = 2231548, upload-time = "2025-11-04T13:42:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/71/0c/85bcbb885b9732c28bec67a222dbed5ed2d77baee1f8bba2002e8cd00c5c/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5", size = 2362571, upload-time = "2025-11-04T13:42:16.208Z" }, + { url = "https://files.pythonhosted.org/packages/c0/4a/412d2048be12c334003e9b823a3fa3d038e46cc2d64dd8aab50b31b65499/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3", size = 2068175, upload-time = "2025-11-04T13:42:18.911Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/c58b6a776b502d0a5540ad02e232514285513572060f0d78f7832ca3c98b/pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425", size = 2177203, upload-time = "2025-11-04T13:42:22.578Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ae/f06ea4c7e7a9eead3d165e7623cd2ea0cb788e277e4f935af63fc98fa4e6/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504", size = 2148191, upload-time = "2025-11-04T13:42:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/c1/57/25a11dcdc656bf5f8b05902c3c2934ac3ea296257cc4a3f79a6319e61856/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5", size = 2343907, upload-time = "2025-11-04T13:42:27.683Z" }, + { url = "https://files.pythonhosted.org/packages/96/82/e33d5f4933d7a03327c0c43c65d575e5919d4974ffc026bc917a5f7b9f61/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3", size = 2322174, upload-time = "2025-11-04T13:42:30.776Z" }, + { url = "https://files.pythonhosted.org/packages/81/45/4091be67ce9f469e81656f880f3506f6a5624121ec5eb3eab37d7581897d/pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460", size = 1990353, upload-time = "2025-11-04T13:42:33.111Z" }, + { url = "https://files.pythonhosted.org/packages/44/8a/a98aede18db6e9cd5d66bcacd8a409fcf8134204cdede2e7de35c5a2c5ef/pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b", size = 2015698, upload-time = "2025-11-04T13:42:35.484Z" }, { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, @@ -2425,6 +1191,14 @@ wheels = [ { 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" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, @@ -2436,93 +1210,103 @@ wheels = [ ] [[package]] -name = "pydantic-graph" -version = "1.105.0" +name = "pygments" +version = "2.19.2" 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" } +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/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" }, + { 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 = "pydantic-settings" -version = "2.14.1" +name = "pyright" +version = "1.1.399" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, + { name = "nodeenv" }, + { name = "typing-extensions" }, ] -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" } +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/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" }, + { 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 = "pygments" -version = "2.19.2" +name = "pytest" +version = "8.4.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" }, +resolution-markers = [ + "python_full_version < '3.10'", ] - -[[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" }, +dependencies = [ + { name = "colorama", marker = "(python_full_version < '3.10' and sys_platform == 'win32') or (python_full_version >= '3.10' and extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2') or (sys_platform != 'win32' and extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, + { name = "exceptiongroup", marker = "python_full_version < '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, + { name = "packaging", marker = "python_full_version < '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, + { name = "pluggy", marker = "python_full_version < '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, + { name = "pygments", marker = "python_full_version < '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, + { name = "tomli", marker = "python_full_version < '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, ] - -[package.optional-dependencies] -crypto = [ - { name = "cryptography" }, +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] [[package]] -name = "pyright" -version = "1.1.399" +name = "pytest" +version = "9.0.2" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version >= '3.10' and python_full_version < '3.14' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version >= '3.10' and extra == 'group-11-agentex-sdk-pydantic-v1' and extra != 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version >= '3.10' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra != 'group-11-agentex-sdk-pydantic-v2'", +] dependencies = [ - { name = "nodeenv" }, - { name = "typing-extensions" }, + { name = "colorama", marker = "(python_full_version >= '3.10' and sys_platform == 'win32') or (python_full_version < '3.10' and extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2') or (sys_platform != 'win32' and extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, + { name = "packaging", marker = "python_full_version >= '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, + { name = "pluggy", marker = "python_full_version >= '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, + { name = "pygments", marker = "python_full_version >= '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, + { name = "tomli", marker = "python_full_version == '3.10.*' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, ] -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" } +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/2f/b5/380380c9e7a534cb1783c70c3e8ac6d1193c599650a55838d0557586796e/pyright-1.1.399-py3-none-any.whl", hash = "sha256:55f9a875ddf23c9698f24208c764465ffdfd38be6265f7faf9a176e1dc549f3b", size = 5592584, upload-time = "2025-04-10T04:40:23.502Z" }, + { 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" -version = "9.0.2" +name = "pytest-asyncio" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, + { name = "backports-asyncio-runner", marker = "python_full_version < '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, + { name = "typing-extensions", marker = "python_full_version < '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, ] [[package]] name = "pytest-asyncio" version = "1.3.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version >= '3.10' and python_full_version < '3.14' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version >= '3.10' and extra == 'group-11-agentex-sdk-pydantic-v1' and extra != 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version >= '3.10' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra != 'group-11-agentex-sdk-pydantic-v2'", +] dependencies = [ - { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "backports-asyncio-runner", marker = "python_full_version == '3.10.*' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, + { name = "typing-extensions", marker = "(python_full_version >= '3.10' and python_full_version < '3.13') or (python_full_version < '3.10' and extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2') or (python_full_version >= '3.13' and extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, ] 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 = [ @@ -2535,7 +1319,8 @@ version = "3.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "execnet" }, - { name = "pytest" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, ] 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 = [ @@ -2547,303 +1332,13 @@ name = "python-dateutil" version = "2.9.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six" }, + { name = "six", marker = "python_full_version < '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, ] 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/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, - { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, - { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, - { 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/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, - { 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" } -dependencies = [ - { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, -] -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/c2/dc/c1f2df4027e82fc54b5a473e4b250f5139faca49a0fbe29a48668d228f34/regex-2026.5.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ccf5249114cc3e772ecdd88a98a86eca0fd74c61ce32a94743758c083fc05d48", size = 489445, upload-time = "2026-05-09T23:12:06.111Z" }, - { url = "https://files.pythonhosted.org/packages/03/d2/59f01110660081cce9c0bc30ebd0b5ee250dacf658e3248ed92f01e0e8ee/regex-2026.5.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46f1326ca6e65b0879d23ca302c0f2415aad42ff0309b9c818e7949fe19a41d8", size = 291271, upload-time = "2026-05-09T23:12:07.731Z" }, - { url = "https://files.pythonhosted.org/packages/58/b6/14b2c84ff90ddb370c81d27503f4a0fcf071496416f4855f6cc8c5d81c35/regex-2026.5.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef31cbfe458e21c6122ba8150ff060e0c7789ed0d26eb423f25472584920b555", size = 289212, upload-time = "2026-05-09T23:12:09.266Z" }, - { url = "https://files.pythonhosted.org/packages/03/d0/4db86529117320de0c84afd90e70bb47434625875e34fcef9d8c127c5b16/regex-2026.5.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:992604d02e6d9c6d786c24a706a71ecffe1020fc1ef264044474cd81fa2c3919", size = 792310, upload-time = "2026-05-09T23:12:11.416Z" }, - { url = "https://files.pythonhosted.org/packages/07/78/fe4800cd322f862ecffd2d553409b20d80650e5ed71b9d178f853d020b82/regex-2026.5.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9411dd64ca95477225734a93dfc8583b51916b8d5942f99d6cac21e09965451", size = 861721, upload-time = "2026-05-09T23:12:13.681Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d0/b3618a895dd8feb897c61bb2954edd265e1767d82a01d53065d5871127a3/regex-2026.5.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4a3ff360dfb836fecdb93a4598f9d6e2ac81e3e397125145c6221bf58cf4c", size = 906460, upload-time = "2026-05-09T23:12:15.443Z" }, - { url = "https://files.pythonhosted.org/packages/33/6f/1481597e859ef19508b345eec4afd1416ed6e6b459c75a64026ef193aecf/regex-2026.5.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a661a7d270a61f7cf460caee8b9fa2d5ef9e5c681234bcb9e0fe14f488e7dfc", size = 799843, upload-time = "2026-05-09T23:12:16.892Z" }, - { url = "https://files.pythonhosted.org/packages/73/59/955734c803f59108deccba3597ae440c76b62a652733c0006e6243758420/regex-2026.5.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f079e50a0d3cc3cd5091fa9ff45869a2e6b2cd35895731edafb0327901a8d86d", size = 773610, upload-time = "2026-05-09T23:12:19.127Z" }, - { url = "https://files.pythonhosted.org/packages/68/8f/70c04a236d651c81881dac42ef8538bddda6121434509d0a22d9e601503b/regex-2026.5.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4ebe8f0b5ec5a5024dc4a4c59f444c4e9afc5f2abdbb8962065b75d27fb971f9", size = 781645, upload-time = "2026-05-09T23:12:20.806Z" }, - { url = "https://files.pythonhosted.org/packages/1d/96/05c7434d88185e5d27fe54aeb74df86bd77cd79f52f0b4eae54faa8fea70/regex-2026.5.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:97cf3bc1b7d7d2306772ec07366c80d9df00ff79e79cea32898883a646d2fae2", size = 854473, upload-time = "2026-05-09T23:12:22.465Z" }, - { url = "https://files.pythonhosted.org/packages/4e/c1/6e3d8202d981f3117004bf341ee74893ba4ba8a9fbaf4b94615846550a08/regex-2026.5.9-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0f9eede6a5cbdc02d4978090186390936e1776a7d1359b21e41014c609880bcf", size = 763311, upload-time = "2026-05-09T23:12:24.351Z" }, - { url = "https://files.pythonhosted.org/packages/93/c7/e7737f1526b3fb32bd4c337fd6c71c3ebb5c8296fc34d11197e0955d2e35/regex-2026.5.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:01f0f5f55f4b64dacec85dc116d3c05fd23ad3ff037bbc73a2085775953c2611", size = 844593, upload-time = "2026-05-09T23:12:26.341Z" }, - { url = "https://files.pythonhosted.org/packages/a5/27/0daffb1a535bb39f422c3d200f4ab023c71110ad66a32b366bee708baba0/regex-2026.5.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1268eddd8486dc561d08eee1156e40aa3a8fe10f4bdec8fa653b455fcbffd12c", size = 789167, upload-time = "2026-05-09T23:12:27.975Z" }, - { url = "https://files.pythonhosted.org/packages/ce/fc/294fe4fac4f2ed67207b17471815870c1c45b3a489e08e0ac96daea16ef6/regex-2026.5.9-cp311-cp311-win32.whl", hash = "sha256:8676474c07469d6f33dd1085ca2cd45f65785f32518f2b20e36d9953ca07f994", size = 266249, upload-time = "2026-05-09T23:12:30.141Z" }, - { url = "https://files.pythonhosted.org/packages/d0/b0/8dce459f6245bcf8f6e9f23ac9569f1a0f15c131cc0745e82b43226204cf/regex-2026.5.9-cp311-cp311-win_amd64.whl", hash = "sha256:246de9d60aa3f8538b519834dd95cbf276ea263d6a7bd5a3666dc3fa0230505b", size = 278423, upload-time = "2026-05-09T23:12:31.676Z" }, - { url = "https://files.pythonhosted.org/packages/db/8d/f9aeff6ad63a3ef720386f2907e6d34a35a510a6e498ebad28b0fb3f6ab6/regex-2026.5.9-cp311-cp311-win_arm64.whl", hash = "sha256:d726ca3f0d76969bf1e8e477d160d3d666bbf999f6860bd314889e5345782046", size = 270420, upload-time = "2026-05-09T23:12:33.194Z" }, - { 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" @@ -2858,152 +1353,16 @@ wheels = [ [[package]] name = "rich" -version = "13.9.4" +version = "14.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, { 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" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } 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/4f/a0/acf8b6fc20bfdcd3a45bd3f57680fb198e157b7e997b9123b10763798bd2/rpds_py-2026.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036", size = 355609, upload-time = "2026-05-28T11:58:50.78Z" }, - { url = "https://files.pythonhosted.org/packages/b6/95/f8203fd997484b1690a6869cd0e503b6c3c6be55b0ecc36d1a491fe742f0/rpds_py-2026.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc", size = 348460, upload-time = "2026-05-28T11:58:52.374Z" }, - { url = "https://files.pythonhosted.org/packages/33/8c/b47326ad2f0be545a5e5c1a55937a12afaea7d392ba2837bb9680f57e6c9/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164", size = 381031, upload-time = "2026-05-28T11:58:53.775Z" }, - { url = "https://files.pythonhosted.org/packages/22/0b/e83bbd97ffac6f6389b605cd4e1c8ac5761dc7e977769c9255d8c5adb7bd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead", size = 387121, upload-time = "2026-05-28T11:58:55.243Z" }, - { url = "https://files.pythonhosted.org/packages/fd/0e/d285d1bc8864245919c61e1ca82263e4a66d337759c3a4cef72766ff9afc/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece", size = 501026, upload-time = "2026-05-28T11:58:56.788Z" }, - { url = "https://files.pythonhosted.org/packages/86/06/ccb2109a1e543437b5e43816f2b43b9554cc6783145528a4e3711e05c011/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb", size = 391865, upload-time = "2026-05-28T11:58:58.298Z" }, - { url = "https://files.pythonhosted.org/packages/3d/33/237173db1cfef10105b3839a24de00eb8d2a523711add4632447cdf0aedd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda", size = 378012, upload-time = "2026-05-28T11:58:59.589Z" }, - { url = "https://files.pythonhosted.org/packages/97/64/1eae54e34d5161f9969295e80bd6b62a55f2b6ac5f2a5b60d02c2140e758/rpds_py-2026.5.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a", size = 391111, upload-time = "2026-05-28T11:59:01.104Z" }, - { url = "https://files.pythonhosted.org/packages/d8/34/5bb334a5a0f65d77869217c4654f34c78a7d11b93938a3c076a2edeafc52/rpds_py-2026.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0", size = 409225, upload-time = "2026-05-28T11:59:02.433Z" }, - { url = "https://files.pythonhosted.org/packages/16/0f/007ec21283b5b040b4ec3bd95e0402591e22bfa7d5c93dfe01c465c2d2d7/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a", size = 556487, upload-time = "2026-05-28T11:59:04.012Z" }, - { url = "https://files.pythonhosted.org/packages/ff/10/5437c94508169b6b22d8418fef7a66e9ffb5f3b9e9c94460f2eedafe06ff/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2", size = 620798, upload-time = "2026-05-28T11:59:05.485Z" }, - { url = "https://files.pythonhosted.org/packages/e0/d5/9937dce4d6bda74157b954e7d1460db05a22f5929dccfeeba1ed27a93df0/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2", size = 584053, upload-time = "2026-05-28T11:59:06.837Z" }, - { url = "https://files.pythonhosted.org/packages/6c/31/750617dd0ae1752471bf43f9e41d263398fae7cde7849d23b8574a70e617/rpds_py-2026.5.1-cp311-cp311-win32.whl", hash = "sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f", size = 214390, upload-time = "2026-05-28T11:59:08.402Z" }, - { url = "https://files.pythonhosted.org/packages/3c/bb/3dcab0e1d9516303f2eb672a5d6f62eca5a69e2886301e9c8c54b520c39b/rpds_py-2026.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a", size = 231097, upload-time = "2026-05-28T11:59:09.786Z" }, - { url = "https://files.pythonhosted.org/packages/49/d6/c6bbf5cb1cf12b9732df8074b57f6ef8341ba884c95d40632ae8bddb44e4/rpds_py-2026.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b", size = 226361, upload-time = "2026-05-28T11:59:11.079Z" }, - { 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" }, - { url = "https://files.pythonhosted.org/packages/42/56/3fe0fb34820ff667be791b3a3c22b85e8bcba54e9c832f47438c191fa7be/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea", size = 357151, upload-time = "2026-05-28T12:01:53.43Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f2/3eb9ccdb9f143b8c9b003978898cb497f942a324c077401e6b8834238e63/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb", size = 350195, upload-time = "2026-05-28T12:01:54.901Z" }, - { url = "https://files.pythonhosted.org/packages/a7/24/dbda232bc4f3ed732120692ab0d2c8402cb020516556d8bee622dcef2413/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df", size = 381850, upload-time = "2026-05-28T12:01:56.601Z" }, - { url = "https://files.pythonhosted.org/packages/40/30/32e769839a358f78810c234f160f2cc21d1e4e47e1c0e0e0d535be5a0219/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7", size = 387899, upload-time = "2026-05-28T12:01:58.212Z" }, - { url = "https://files.pythonhosted.org/packages/ab/86/ec84d243aadb3b34b71dd26a010d0930b2d284ff5fc9a69fec53810ee6fd/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc", size = 501618, upload-time = "2026-05-28T12:01:59.888Z" }, - { url = "https://files.pythonhosted.org/packages/74/25/b60e52686bbff777a64f9e4f4d3dd57980dc846913777177a2c92e4937aa/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162", size = 394003, upload-time = "2026-05-28T12:02:01.482Z" }, - { url = "https://files.pythonhosted.org/packages/9b/c7/b3a6a588cc2219510ef3f42e207483a93950bedd1e3a0fd4015c95cff9e5/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251", size = 379778, upload-time = "2026-05-28T12:02:03.197Z" }, - { url = "https://files.pythonhosted.org/packages/31/00/c7dba3fc8a3da8cb3f6db1eb3386be4d79c2e97c6890d20eb9ac66ae8c43/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a", size = 392359, upload-time = "2026-05-28T12:02:04.817Z" }, - { url = "https://files.pythonhosted.org/packages/93/dd/472ba494c70753f93745992c99855bee0636daf74e6984e5e003f150316f/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b", size = 412820, upload-time = "2026-05-28T12:02:06.401Z" }, - { url = "https://files.pythonhosted.org/packages/1d/6f/93831a3bfe789542ed0c1d0d74b78b440f055d6dc3ea4640eba2d95e6e23/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34", size = 557243, upload-time = "2026-05-28T12:02:08.013Z" }, - { url = "https://files.pythonhosted.org/packages/1f/ff/0b3d604614ffc77522c6b288fdbce68957eb583da1002aa65ba38ac0ee40/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c", size = 623541, upload-time = "2026-05-28T12:02:09.661Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ea/e7b0251441da9adfeaebcf29601d10f2a1455fcf0772fae9e7e19032bd96/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049", size = 586326, upload-time = "2026-05-28T12:02:11.47Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, ] [[package]] @@ -3032,49 +1391,6 @@ wheels = [ { 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.2.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/3e/13/181f6b5a0e3fe5c2ca8b7b39e024ed11feba2cf0c879a6d77d84c8060383/scale_gp_beta-0.2.0.tar.gz", hash = "sha256:d4eac4a178ea4b7f21cfe2b421107009b69c4a5c1cbd2c7c864142c30ffda01c", size = 434620, upload-time = "2026-05-04T16:35:53.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e0/84d284fd5268c4dcaa54dd7209d43dbe41d2120834ee7a8b245184fe13d5/scale_gp_beta-0.2.0-py3-none-any.whl", hash = "sha256:87946b4618c464711bb7c8b132540112a1a558a57b31999f93cccb5da8339643", size = 410408, upload-time = "2026-05-04T16:35:52.286Z" }, -] - -[[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" @@ -3094,142 +1410,130 @@ wheels = [ ] [[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" +name = "time-machine" +version = "2.19.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/1a/4c/1bc81f4cd53e827c4ee67ca951b5935724716049452d8dfa09b8b82372bb/tiktoken-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7bfe1849caa65d1e1d9871817170ec497bbb7984e182012e1bdce72f66608cdb", size = 1036353, upload-time = "2026-05-15T04:50:21.757Z" }, - { url = "https://files.pythonhosted.org/packages/75/91/10b9c7076bc02c246c853201fdbbe300a4b8c5ed7b84c25f7403f4e32655/tiktoken-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:91c180fe255bd5a86d8316210d2833a1d4d33d026cd86a67812f4773743c8d26", size = 984644, upload-time = "2026-05-15T04:50:23.256Z" }, - { url = "https://files.pythonhosted.org/packages/4e/e4/fceae98015fab47fcd49b8bd7f46145bcd187a47e0add1e5378ed67ef980/tiktoken-0.13.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:059c8ecf554eb5b41e6e054ba467b871b03277d267dee7244380aca4359747d4", size = 1119261, upload-time = "2026-05-15T04:50:24.348Z" }, - { url = "https://files.pythonhosted.org/packages/f9/39/fe42ad00de01a8c4a49ad8649a2c8a316835a9cad5961b11d21eac0020a5/tiktoken-0.13.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:36217497eaffc158607a3b26f065300db2aefd43b115263f3b9688ce38146173", size = 1138253, upload-time = "2026-05-15T04:50:25.505Z" }, - { url = "https://files.pythonhosted.org/packages/03/c4/ccee1ecccca107e9a16efcecdeeb964c325305038554d466ece65b42338f/tiktoken-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:303f7d91b4fce3baddbcde05c139091d4caa5026ac7214c1dc7ff7a71ee429ff", size = 1185747, upload-time = "2026-05-15T04:50:27.02Z" }, - { url = "https://files.pythonhosted.org/packages/9d/03/cd0cba295522b91eb55c6b2704f1df895f8226cfe60ab10d4d51d0cc9e69/tiktoken-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5d48843bee149630eb735a99e1f4a85b47308d21868ea63163f6e87768d3cfed", size = 1241265, upload-time = "2026-05-15T04:50:28.815Z" }, - { url = "https://files.pythonhosted.org/packages/7e/25/a10efd564402d82c2ff50d12057353ace447aa8007deceaa48641f63d35c/tiktoken-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:fc1c44cd37b43fc46bae593129164f4f281e82ea116b57a85aa81bda57eafc94", size = 876509, upload-time = "2026-05-15T04:50:30.026Z" }, - { 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" }, +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "python-dateutil", marker = "python_full_version < '3.10' or (extra == 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/1b5fdd165f61b67f445fac2a7feb0c655118edef429cd09ff5a8067f7f1d/time_machine-2.19.0.tar.gz", hash = "sha256:7c5065a8b3f2bbb449422c66ef71d114d3f909c276a6469642ecfffb6a0fcd29", size = 14576, upload-time = "2025-08-19T17:22:08.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8f/19125611ebbcb3a14da14cd982b9eb4573e2733db60c9f1fbf6a39534f40/time_machine-2.19.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b5169018ef47206997b46086ce01881cd3a4666fd2998c9d76a87858ca3e49e9", size = 19659, upload-time = "2025-08-19T17:20:30.062Z" }, + { url = "https://files.pythonhosted.org/packages/74/da/9b0a928321e7822a3ff96dbd1eae089883848e30e9e1b149b85fb96ba56b/time_machine-2.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:85bb7ed440fccf6f6d0c8f7d68d849e7c3d1f771d5e0b2cdf871fa6561da569f", size = 15157, upload-time = "2025-08-19T17:20:31.931Z" }, + { url = "https://files.pythonhosted.org/packages/36/ff/d7e943422038f5f2161fe2c2d791e64a45be691ef946020b20f3a6efc4d4/time_machine-2.19.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a3b12028af1cdc09ccd595be2168b7b26f206c1e190090b048598fbe278beb8e", size = 32860, upload-time = "2025-08-19T17:20:33.241Z" }, + { url = "https://files.pythonhosted.org/packages/fc/80/2b0f1070ed9808ee7da7a6da62a4a0b776957cb4d861578348f86446e778/time_machine-2.19.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c261f073086cf081d1443cbf7684148c662659d3d139d06b772bfe3fe7cc71a6", size = 34510, upload-time = "2025-08-19T17:20:34.221Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b4/48038691c8d89924b36c83335a73adeeb68c884f5a1da08a5b17b8a956f3/time_machine-2.19.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:011954d951230a9f1079f22b39ed1a3a9abb50ee297dfb8c557c46351659d94d", size = 36204, upload-time = "2025-08-19T17:20:35.163Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/60e8adb541df195e83cb74b720b2cfb1f22ed99c5a7f8abf2a9ab3442cb5/time_machine-2.19.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b0f83308b29c7872006803f2e77318874eb84d0654f2afe0e48e3822e7a2e39b", size = 34936, upload-time = "2025-08-19T17:20:36.61Z" }, + { url = "https://files.pythonhosted.org/packages/5e/72/e8cee59c6cd99dd3b25b8001a0253e779a286aa8f44d5b40777cbd66210b/time_machine-2.19.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:39733ef844e2984620ec9382a42d00cccc4757d75a5dd572be8c2572e86e50b9", size = 32932, upload-time = "2025-08-19T17:20:37.901Z" }, + { url = "https://files.pythonhosted.org/packages/2c/eb/83f300d93c1504965d944e03679f1c943a923bce2d0fdfadef0e2e22cc13/time_machine-2.19.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f8db99f6334432e9ffbf00c215caf2ae9773f17cec08304d77e9e90febc3507b", size = 34010, upload-time = "2025-08-19T17:20:39.202Z" }, + { url = "https://files.pythonhosted.org/packages/e1/77/f35f2500e04daac5033a22fbfd17e68467822b8406ee77966bf222ccaa26/time_machine-2.19.0-cp310-cp310-win32.whl", hash = "sha256:72bf66cd19e27ffd26516b9cbe676d50c2e0b026153289765dfe0cf406708128", size = 17121, upload-time = "2025-08-19T17:20:40.108Z" }, + { url = "https://files.pythonhosted.org/packages/db/df/32d3e0404be1760a64a44caab2af34b07e952bfe00a23134fea9ddba3e8a/time_machine-2.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:46f1c945934ce3d6b4f388b8e581fce7f87ec891ea90d7128e19520e434f96f0", size = 17957, upload-time = "2025-08-19T17:20:41.079Z" }, + { url = "https://files.pythonhosted.org/packages/66/df/598a71a1afb4b509a4587273b76590b16d9110a3e9106f01eedc68d02bb2/time_machine-2.19.0-cp310-cp310-win_arm64.whl", hash = "sha256:fb4897c7a5120a4fd03f0670f332d83b7e55645886cd8864a71944c4c2e5b35b", size = 16821, upload-time = "2025-08-19T17:20:41.967Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ed/4815ebcc9b6c14273f692b9be38a9b09eae52a7e532407cc61a51912b121/time_machine-2.19.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5ee91664880434d98e41585c3446dac7180ec408c786347451ddfca110d19296", size = 19342, upload-time = "2025-08-19T17:20:43.207Z" }, + { url = "https://files.pythonhosted.org/packages/ee/08/154cce8b11b60d8238b0b751b8901d369999f4e8f7c3a5f917caa5d95b0b/time_machine-2.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ed3732b83a893d1c7b8cabde762968b4dc5680ee0d305b3ecca9bb516f4e3862", size = 14978, upload-time = "2025-08-19T17:20:44.134Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b7/b689d8c8eeca7af375cfcd64973e49e83aa817cc00f80f98548d42c0eb50/time_machine-2.19.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6ba0303e9cc9f7f947e344f501e26bedfb68fab521e3c2729d370f4f332d2d55", size = 30964, upload-time = "2025-08-19T17:20:45.366Z" }, + { url = "https://files.pythonhosted.org/packages/80/91/38bf9c79674e95ce32e23c267055f281dff651eec77ed32a677db3dc011a/time_machine-2.19.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2851825b524a988ee459c37c1c26bdfaa7eff78194efb2b562ea497a6f375b0a", size = 32606, upload-time = "2025-08-19T17:20:46.693Z" }, + { url = "https://files.pythonhosted.org/packages/19/4a/e9222d85d4de68975a5e799f539a9d32f3a134a9101fca0a61fa6aa33d68/time_machine-2.19.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68d32b09ecfd7fef59255c091e8e7c24dd117f882c4880b5c7ab8c5c32a98f89", size = 34405, upload-time = "2025-08-19T17:20:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/09480d608d42d6876f9ff74593cfc9197a7eb2c31381a74fb2b145575b65/time_machine-2.19.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60c46ab527bf2fa144b530f639cc9e12803524c9e1f111dc8c8f493bb6586eeb", size = 33181, upload-time = "2025-08-19T17:20:48.937Z" }, + { url = "https://files.pythonhosted.org/packages/84/64/f9359e000fad32d9066305c48abc527241d608bcdf77c19d67d66e268455/time_machine-2.19.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:56f26ab9f0201c453d18fe76bb7d1cf05fe58c1b9d9cb0c7d243d05132e01292", size = 31036, upload-time = "2025-08-19T17:20:50.276Z" }, + { url = "https://files.pythonhosted.org/packages/71/0d/fab2aacec71e3e482bd7fce0589381f9414a4a97f8766bddad04ad047b7b/time_machine-2.19.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6c806cf3c1185baa1d807b7f51bed0db7a6506832c961d5d1b4c94c775749bc0", size = 32145, upload-time = "2025-08-19T17:20:51.449Z" }, + { url = "https://files.pythonhosted.org/packages/44/fb/faeba2405fb27553f7b28db441a500e2064ffdb2dcba001ee315fdd2c121/time_machine-2.19.0-cp311-cp311-win32.whl", hash = "sha256:b30039dfd89855c12138095bee39c540b4633cbc3684580d684ef67a99a91587", size = 17004, upload-time = "2025-08-19T17:20:52.38Z" }, + { url = "https://files.pythonhosted.org/packages/2f/84/87e483d660ca669426192969280366635c845c3154a9fe750be546ed3afc/time_machine-2.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:13ed8b34430f1de79905877f5600adffa626793ab4546a70a99fb72c6a3350d8", size = 17822, upload-time = "2025-08-19T17:20:53.348Z" }, + { url = "https://files.pythonhosted.org/packages/41/f4/ebf7bbf5047854a528adaf54a5e8780bc5f7f0104c298ab44566a3053bf8/time_machine-2.19.0-cp311-cp311-win_arm64.whl", hash = "sha256:cc29a50a0257d8750b08056b66d7225daab47606832dea1a69e8b017323bf511", size = 16680, upload-time = "2025-08-19T17:20:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/9b/aa/7e00614d339e4d687f6e96e312a1566022528427d237ec639df66c4547bc/time_machine-2.19.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c85cf437dc3c07429456d8d6670ac90ecbd8241dcd0fbf03e8db2800576f91ff", size = 19308, upload-time = "2025-08-19T17:20:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/ab/3c/bde3c757394f5bca2fbc1528d4117960a26c38f9b160bf471b38d2378d8f/time_machine-2.19.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d9238897e8ef54acdf59f5dff16f59ca0720e7c02d820c56b4397c11db5d3eb9", size = 15019, upload-time = "2025-08-19T17:20:56.204Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e0/8ca916dd918018352d377f1f5226ee071cfbeb7dbbde2b03d14a411ac2b1/time_machine-2.19.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e312c7d5d6bfffb96c6a7b39ff29e3046de100d7efaa3c01552654cfbd08f14c", size = 33079, upload-time = "2025-08-19T17:20:57.166Z" }, + { url = "https://files.pythonhosted.org/packages/48/69/184a0209f02dd0cb5e01e8d13cd4c97a5f389c4e3d09b95160dd676ad1e7/time_machine-2.19.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:714c40b2c90d1c57cc403382d5a9cf16e504cb525bfe9650095317da3c3d62b5", size = 34925, upload-time = "2025-08-19T17:20:58.117Z" }, + { url = "https://files.pythonhosted.org/packages/43/42/4bbf4309e8e57cea1086eb99052d97ff6ddecc1ab6a3b07aa4512f8bf963/time_machine-2.19.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2eaa1c675d500dc3ccae19e9fb1feff84458a68c132bbea47a80cc3dd2df7072", size = 36384, upload-time = "2025-08-19T17:20:59.108Z" }, + { url = "https://files.pythonhosted.org/packages/b1/af/9f510dc1719157348c1a2e87423aed406589070b54b503cb237d9bf3a4fe/time_machine-2.19.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e77a414e9597988af53b2b2e67242c9d2f409769df0d264b6d06fda8ca3360d4", size = 34881, upload-time = "2025-08-19T17:21:00.116Z" }, + { url = "https://files.pythonhosted.org/packages/ca/28/61764a635c70cc76c76ba582dfdc1a84834cddaeb96789023af5214426b2/time_machine-2.19.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cd93996970e11c382b04d4937c3cd0b0167adeef14725ece35aae88d8a01733c", size = 32931, upload-time = "2025-08-19T17:21:01.095Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e0/f028d93b266e6ade8aca5851f76ebbc605b2905cdc29981a2943b43e1a6c/time_machine-2.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8e20a6d8d6e23174bd7e931e134d9610b136db460b249d07e84ecdad029ec352", size = 34241, upload-time = "2025-08-19T17:21:02.052Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a6/36d1950ed1d3f613158024cf1dcc73db1d9ef0b9117cf51ef2e37dc06499/time_machine-2.19.0-cp312-cp312-win32.whl", hash = "sha256:95afc9bc65228b27be80c2756799c20b8eb97c4ef382a9b762b6d7888bc84099", size = 17021, upload-time = "2025-08-19T17:21:03.374Z" }, + { url = "https://files.pythonhosted.org/packages/b1/0d/e2dce93355abda3cac69e77fe96566757e98b8fe7fdcbddce89c9ced3f5f/time_machine-2.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:e84909af950e2448f4e2562ea5759c946248c99ab380d2b47d79b62bd76fa236", size = 17857, upload-time = "2025-08-19T17:21:04.331Z" }, + { url = "https://files.pythonhosted.org/packages/eb/28/50ae6fb83b7feeeca7a461c0dc156cf7ef5e6ef594a600d06634fde6a2cb/time_machine-2.19.0-cp312-cp312-win_arm64.whl", hash = "sha256:0390a1ea9fa7e9d772a39b7c61b34fdcca80eb9ffac339cc0441c6c714c81470", size = 16677, upload-time = "2025-08-19T17:21:05.39Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b8/24ebce67aa531bae2cbe164bb3f4abc6467dc31f3aead35e77f5a075ea3e/time_machine-2.19.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5e172866753e6041d3b29f3037dc47c20525176a494a71bbd0998dfdc4f11f2f", size = 19373, upload-time = "2025-08-19T17:21:06.701Z" }, + { url = "https://files.pythonhosted.org/packages/53/a5/c9a5240fd2f845d3ff9fa26f8c8eaa29f7239af9d65007e61d212250f15b/time_machine-2.19.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f70f68379bd6f542ae6775cce9a4fa3dcc20bf7959c42eaef871c14469e18097", size = 15056, upload-time = "2025-08-19T17:21:07.667Z" }, + { url = "https://files.pythonhosted.org/packages/b9/92/66cce5d2fb2a5e68459aca85fd18a7e2d216f725988940cd83f96630f2f1/time_machine-2.19.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e69e0b0f694728a00e72891ef8dd00c7542952cb1c87237db594b6b27d504a96", size = 33172, upload-time = "2025-08-19T17:21:08.619Z" }, + { url = "https://files.pythonhosted.org/packages/ae/20/b499e9ab4364cd466016c33dcdf4f56629ca4c20b865bd4196d229f31d92/time_machine-2.19.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3ae0a8b869574301ec5637e32c270c7384cca5cd6e230f07af9d29271a7fa293", size = 35042, upload-time = "2025-08-19T17:21:09.622Z" }, + { url = "https://files.pythonhosted.org/packages/41/32/b252d3d32791eb16c07d553c820dbc33d9c7fa771de3d1c602190bded2b7/time_machine-2.19.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:554e4317de90e2f7605ff80d153c8bb56b38c0d0c0279feb17e799521e987b8c", size = 36535, upload-time = "2025-08-19T17:21:10.571Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/4d0470062b9742e1b040ab81bad04d1a5d1de09806507bb6188989cfa1a7/time_machine-2.19.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6567a5ec5538ed550539ac29be11b3cb36af1f9894e2a72940cba0292cc7c3c9", size = 34945, upload-time = "2025-08-19T17:21:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/24/71/2f741b29d98b1c18f6777a32236497c3d3264b6077e431cea4695684c8a1/time_machine-2.19.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:82e9ffe8dfff07b0d810a2ad015a82cd78c6a237f6c7cf185fa7f747a3256f8a", size = 33014, upload-time = "2025-08-19T17:21:12.858Z" }, + { url = "https://files.pythonhosted.org/packages/e8/83/ca8dba6106562843fd99f672e5aaf95badbc10f4f13f7cfe8d8640a7019d/time_machine-2.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7e1c4e578cdd69b3531d8dd3fbcb92a0cd879dadb912ee37af99c3a9e3c0d285", size = 34350, upload-time = "2025-08-19T17:21:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/21/7f/34fe540450e18d0a993240100e4b86e8d03d831b92af8bb6ddb2662dc6fc/time_machine-2.19.0-cp313-cp313-win32.whl", hash = "sha256:72dbd4cbc3d96dec9dd281ddfbb513982102776b63e4e039f83afb244802a9e5", size = 17047, upload-time = "2025-08-19T17:21:14.874Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5d/c8be73df82c7ebe7cd133279670e89b8b110af3ce1412c551caa9d08e625/time_machine-2.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:e17e3e089ac95f9a145ce07ff615e3c85674f7de36f2d92aaf588493a23ffb4b", size = 17868, upload-time = "2025-08-19T17:21:15.819Z" }, + { url = "https://files.pythonhosted.org/packages/92/13/2dfd3b8fb285308f61cd7aa9bfa96f46ddf916e3549a0f0afd094c556599/time_machine-2.19.0-cp313-cp313-win_arm64.whl", hash = "sha256:149072aff8e3690e14f4916103d898ea0d5d9c95531b6aa0995251c299533f7b", size = 16710, upload-time = "2025-08-19T17:21:16.748Z" }, + { url = "https://files.pythonhosted.org/packages/05/c1/deebb361727d2c5790f9d4d874be1b19afd41f4375581df465e6718b46a2/time_machine-2.19.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f3589fee1ed0ab6ee424a55b0ea1ec694c4ba64cc26895bcd7d99f3d1bc6a28a", size = 20053, upload-time = "2025-08-19T17:21:17.704Z" }, + { url = "https://files.pythonhosted.org/packages/45/e8/fe3376951e6118d8ec1d1f94066a169b791424fe4a26c7dfc069b153ee08/time_machine-2.19.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7887e85275c4975fe54df03dcdd5f38bd36be973adc68a8c77e17441c3b443d6", size = 15423, upload-time = "2025-08-19T17:21:18.668Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/f88d95cd1a87c650cf3749b4d64afdaf580297aa18ad7f4b44ec9d252dfc/time_machine-2.19.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ce0be294c209928563fcce1c587963e60ec803436cf1e181acd5bc1e425d554b", size = 39630, upload-time = "2025-08-19T17:21:19.645Z" }, + { url = "https://files.pythonhosted.org/packages/cc/5d/65a5c48a65357e56ec6f032972e4abd1c02d4fca4b0717a3aaefd19014d4/time_machine-2.19.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a62fd1ab380012c86f4c042010418ed45eb31604f4bf4453e17c9fa60bc56a29", size = 41242, upload-time = "2025-08-19T17:21:20.979Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/fe5209e1615fde0a8cad6c4e857157b150333ed1fe31a7632b08cfe0ebdd/time_machine-2.19.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b25ec853a4530a5800731257f93206b12cbdee85ede964ebf8011b66086a7914", size = 44278, upload-time = "2025-08-19T17:21:21.984Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3a/a5e5fe9c5d614cde0a9387ff35e8dfd12c5ef6384e4c1a21b04e6e0b905d/time_machine-2.19.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a430e4d0e0556f021a9c78e9b9f68e5e8910bdace4aa34ed4d1a73e239ed9384", size = 42321, upload-time = "2025-08-19T17:21:23.755Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c5/56eca774e9162bc1ce59111d2bd69140dc8908c9478c92ec7bd15d547600/time_machine-2.19.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2415b7495ec4364c8067071e964fbadfe746dd4cdb43983f2f0bd6ebed13315c", size = 39270, upload-time = "2025-08-19T17:21:26.009Z" }, + { url = "https://files.pythonhosted.org/packages/9b/69/5dd0c420667578169a12acc8c8fd7452e8cfb181e41c9b4ac7e88fa36686/time_machine-2.19.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbfc6b90c10f288594e1bf89a728a98cc0030791fd73541bbdc6b090aff83143", size = 40193, upload-time = "2025-08-19T17:21:27.054Z" }, + { url = "https://files.pythonhosted.org/packages/75/a7/de974d421bd55c9355583427c2a38fb0237bb5fd6614af492ba89dacb2f9/time_machine-2.19.0-cp313-cp313t-win32.whl", hash = "sha256:16f5d81f650c0a4d117ab08036dc30b5f8b262e11a4a0becc458e7f1c011b228", size = 17542, upload-time = "2025-08-19T17:21:28.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/0a/aa0d05becd5d06ae8d3f16d657dc8cc9400c8d79aef80299de196467ff12/time_machine-2.19.0-cp313-cp313t-win_amd64.whl", hash = "sha256:645699616ec14e147094f601e6ab9553ff6cea37fad9c42720a6d7ed04bcd5dc", size = 18703, upload-time = "2025-08-19T17:21:29.663Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c0/f785a4c7c73aa176510f7c48b84b49c26be84af0d534deb222e0327f750e/time_machine-2.19.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b32daa965d13237536ea3afaa5ad61ade2b2d9314bc3a20196a0d2e1d7b57c6a", size = 17020, upload-time = "2025-08-19T17:21:30.653Z" }, + { url = "https://files.pythonhosted.org/packages/ed/97/c5fb51def06c0b2b6735332ad118ab35b4d9b85368792e5b638e99b1b686/time_machine-2.19.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:31cb43c8fd2d961f31bed0ff4e0026964d2b35e5de9e0fabbfecf756906d3612", size = 19360, upload-time = "2025-08-19T17:21:31.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4e/2d795f7d6b7f5205ffe737a05bb1cf19d8038233b797062b2ef412b8512b/time_machine-2.19.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:bdf481a75afc6bff3e520db594501975b652f7def21cd1de6aa971d35ba644e6", size = 15033, upload-time = "2025-08-19T17:21:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/dd/32/9bad501e360b4e758c58fae616ca5f8c7ad974b343f2463a15b2bf77a366/time_machine-2.19.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:00bee4bb950ac6a08d62af78e4da0cf2b4fc2abf0de2320d0431bf610db06e7c", size = 33379, upload-time = "2025-08-19T17:21:33.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/45/eda0ca4d793dfd162478d6163759b1c6ce7f6e61daa7fd7d62b31f21f87f/time_machine-2.19.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9f02199490906582302ce09edd32394fb393271674c75d7aa76c7a3245f16003", size = 35123, upload-time = "2025-08-19T17:21:34.945Z" }, + { url = "https://files.pythonhosted.org/packages/f0/5a/97e16325442ae5731fcaac794f0a1ef9980eff8a5491e58201d7eb814a34/time_machine-2.19.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e35726c7ba625f844c13b1fc0d4f81f394eefaee1d3a094a9093251521f2ef15", size = 36588, upload-time = "2025-08-19T17:21:35.975Z" }, + { url = "https://files.pythonhosted.org/packages/e8/9d/bf0b2ccc930cc4a316f26f1c78d3f313cd0fa13bb7480369b730a8f129db/time_machine-2.19.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:304315023999cd401ff02698870932b893369e1cfeb2248d09f6490507a92e97", size = 35013, upload-time = "2025-08-19T17:21:37.017Z" }, + { url = "https://files.pythonhosted.org/packages/f0/5a/39ac6a3078174f9715d88364871348b249631f12e76de1b862433b3f8862/time_machine-2.19.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9765d4f003f263ea8bfd90d2d15447ca4b3dfa181922cf6cf808923b02ac180a", size = 33303, upload-time = "2025-08-19T17:21:38.352Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ac/d8646baf9f95f2e792a6d7a7b35e92fca253c4a992afff801beafae0e5c2/time_machine-2.19.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7837ef3fd5911eb9b480909bb93d922737b6bdecea99dfcedb0a03807de9b2d3", size = 34440, upload-time = "2025-08-19T17:21:39.382Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8b/8b6568c5ae966d80ead03ab537be3c6acf2af06fb501c2d466a3162c6295/time_machine-2.19.0-cp314-cp314-win32.whl", hash = "sha256:4bb5bd43b1bdfac3007b920b51d8e761f024ed465cfeec63ac4296922a4ec428", size = 17162, upload-time = "2025-08-19T17:21:40.381Z" }, + { url = "https://files.pythonhosted.org/packages/46/a5/211c1ab4566eba5308b2dc001b6349e3a032e3f6afa67ca2f27ea6b27af5/time_machine-2.19.0-cp314-cp314-win_amd64.whl", hash = "sha256:f583bbd0aa8ab4a7c45a684bf636d9e042d466e30bcbae1d13e7541e2cbe7207", size = 18040, upload-time = "2025-08-19T17:21:41.363Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fc/4c2fb705f6371cb83824da45a8b967514a922fc092a0ef53979334d97a70/time_machine-2.19.0-cp314-cp314-win_arm64.whl", hash = "sha256:f379c6f8a6575a8284592179cf528ce89373f060301323edcc44f1fa1d37be12", size = 16752, upload-time = "2025-08-19T17:21:42.336Z" }, + { url = "https://files.pythonhosted.org/packages/79/ab/6437d18f31c666b5116c97572a282ac2590a82a0a9867746a6647eaf4613/time_machine-2.19.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a3b8981f9c663b0906b05ab4d0ca211fae4b63b47c6ec26de5374fe56c836162", size = 20057, upload-time = "2025-08-19T17:21:43.35Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a2/e03639ec2ba7200328bbcad8a2b2b1d5fccca9cceb9481b164a1cabdcb33/time_machine-2.19.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8e9c6363893e7f52c226afbebb23e825259222d100e67dfd24c8a6d35f1a1907", size = 15430, upload-time = "2025-08-19T17:21:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ff/39e63a48e840f3e36ce24846ee51dd99c6dba635659b1750a2993771e88e/time_machine-2.19.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:206fcd6c9a6f00cac83db446ad1effc530a8cec244d2780af62db3a2d0a9871b", size = 39622, upload-time = "2025-08-19T17:21:45.821Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/ee5ac79c4954768705801e54817c7d58e07e25a0bb227e775f501f3e2122/time_machine-2.19.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf33016a1403c123373ffaeff25e26e69d63bf2c63b6163932efed94160db7ef", size = 41235, upload-time = "2025-08-19T17:21:46.783Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3e/9af5f39525e779185c77285b8bbae15340eeeaa0afb33d458bc8b47d459b/time_machine-2.19.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9247c4bb9bbd3ff584ef4efbdec8efd9f37aa08bcfc4728bde1e489c2cb445bd", size = 44276, upload-time = "2025-08-19T17:21:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/572c7443cc27140bbeae3947279bbd4a120f9e8622253a20637f260b7813/time_machine-2.19.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:77f9bb0b86758d1f2d9352642c874946ad5815df53ef4ca22eb9d532179fe50d", size = 42330, upload-time = "2025-08-19T17:21:48.881Z" }, + { url = "https://files.pythonhosted.org/packages/cf/24/1a81c2e08ee7dae13ec8ceed27a29afa980c3d63852e42f1e023bf0faa03/time_machine-2.19.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0b529e262df3b9c449f427385f4d98250828c879168c2e00eec844439f40b370", size = 39281, upload-time = "2025-08-19T17:21:49.907Z" }, + { url = "https://files.pythonhosted.org/packages/d2/60/6f0d6e5108978ca1a2a4ffb4d1c7e176d9199bb109fd44efe2680c60b52a/time_machine-2.19.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9199246e31cdc810e5d89cb71d09144c4d745960fdb0824da4994d152aca3303", size = 40201, upload-time = "2025-08-19T17:21:50.953Z" }, + { url = "https://files.pythonhosted.org/packages/73/b9/3ea4951e8293b0643feb98c0b9a176fa822154f1810835db3f282968ab10/time_machine-2.19.0-cp314-cp314t-win32.whl", hash = "sha256:0fe81bae55b7aefc2c2a34eb552aa82e6c61a86b3353a3c70df79b9698cb02ca", size = 17743, upload-time = "2025-08-19T17:21:51.948Z" }, + { url = "https://files.pythonhosted.org/packages/e4/8b/cd802884ca8a98e2b6cdc2397d57dd12ff8a7d1481e06fc3fad3d4e7e5ff/time_machine-2.19.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7253791b8d7e7399fbeed7a8193cb01bc004242864306288797056badbdaf80b", size = 18956, upload-time = "2025-08-19T17:21:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/c6/49/cabb1593896082fd55e34768029b8b0ca23c9be8b2dc127e0fc14796d33e/time_machine-2.19.0-cp314-cp314t-win_arm64.whl", hash = "sha256:536bd1ac31ab06a1522e7bf287602188f502dc19d122b1502c4f60b1e8efac79", size = 17068, upload-time = "2025-08-19T17:21:54.064Z" }, + { url = "https://files.pythonhosted.org/packages/d6/05/0608376c3167afe6cf7cdfd2b05c142ea4c42616eee9ba06d1799965806a/time_machine-2.19.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8bb00b30ec9fe56d01e9812df1ffe39f331437cef9bfaebcc81c83f7f8f8ee2", size = 19659, upload-time = "2025-08-19T17:21:55.426Z" }, + { url = "https://files.pythonhosted.org/packages/11/c4/72eb8c7b36830cf36c51d7bc2f1ac313d68881c3a58040fb6b42c4523d20/time_machine-2.19.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d821c60efc08a97cc11e5482798e6fd5eba5c0f22a02db246b50895dbdc0de41", size = 15153, upload-time = "2025-08-19T17:21:56.505Z" }, + { url = "https://files.pythonhosted.org/packages/89/1a/0782e1f5c8ab8809ebd992709e1bb69d67600191baa023af7a5d32023a3c/time_machine-2.19.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb051aec7b3b6e96a200d911c225901e6133ff3da11e470e24111a53bbc13637", size = 32555, upload-time = "2025-08-19T17:21:57.74Z" }, + { url = "https://files.pythonhosted.org/packages/94/b0/8ef58e2f6321851d5900ca3d18044938832c2ed42a2ac7570ca6aa29768a/time_machine-2.19.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe59909d95a2ef5e01ce3354fdea3908404c2932c2069f00f66dff6f27e9363e", size = 34185, upload-time = "2025-08-19T17:21:59.361Z" }, + { url = "https://files.pythonhosted.org/packages/82/74/ce0c9867f788c1fb22c417ec1aae47a24117e53d51f6ff97d7c6ca5392f6/time_machine-2.19.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29e84b8682645b16eb6f9e8ec11c35324ad091841a11cf4fc3fc7f6119094c89", size = 35917, upload-time = "2025-08-19T17:22:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/d2/70/6f97a8f552dbaa66feb10170b5726dab74bc531673d1ed9d6f271547e54c/time_machine-2.19.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a11f1c0e0d06023dc01614c964e256138913551d3ae6dca5148f79081156336", size = 34584, upload-time = "2025-08-19T17:22:01.447Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/cf139088ce537c15d7f03cf56ec317d3a5cfb520e30aa711ea0248d0ae8a/time_machine-2.19.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:57a235a6307c54df50e69f1906e2f199e47da91bde4b886ee05aff57fe4b6bf6", size = 32608, upload-time = "2025-08-19T17:22:02.548Z" }, + { url = "https://files.pythonhosted.org/packages/b1/17/0ec41ef7a30c6753fb226a28b74162b264b35724905ced4098f2f5076ded/time_machine-2.19.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:426aba552f7af9604adad9ef570c859af7c1081d878db78089fac159cd911b0a", size = 33686, upload-time = "2025-08-19T17:22:03.606Z" }, + { url = "https://files.pythonhosted.org/packages/b0/19/586f15159083ec84f178d494c60758c46603b00c9641b04deb63f1950128/time_machine-2.19.0-cp39-cp39-win32.whl", hash = "sha256:67772c7197a3a712d1b970ed545c6e98db73524bd90e245fd3c8fa7ad7630768", size = 17133, upload-time = "2025-08-19T17:22:04.989Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c2/bfe4b906a9fe0bf2d011534314212ed752d6b8f392c9c82f6ac63dccc5ab/time_machine-2.19.0-cp39-cp39-win_amd64.whl", hash = "sha256:011d7859089263204dc5fdf83dce7388f986fe833c9381d6106b4edfda2ebd3e", size = 17972, upload-time = "2025-08-19T17:22:06.026Z" }, + { url = "https://files.pythonhosted.org/packages/5d/73/182343eba05aa5787732aaa68f3b3feb5e40ddf86b928ae941be45646393/time_machine-2.19.0-cp39-cp39-win_arm64.whl", hash = "sha256:e1af66550fa4685434f00002808a525f176f1f92746646c0019bb86fbff48b27", size = 16820, upload-time = "2025-08-19T17:22:07.227Z" }, ] [[package]] name = "time-machine" version = "3.2.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version >= '3.10' and python_full_version < '3.14' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra == 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version >= '3.10' and extra == 'group-11-agentex-sdk-pydantic-v1' and extra != 'group-11-agentex-sdk-pydantic-v2'", + "python_full_version >= '3.10' and extra != 'group-11-agentex-sdk-pydantic-v1' and extra != 'group-11-agentex-sdk-pydantic-v2'", +] 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/9c/31/6bf41cb4a326230518d9b76c910dfc11d4fc23444d1cbfdf2d7652bd99f4/time_machine-3.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:68142c070e78b62215d8029ec7394905083a4f9aacb0a2a11514ce70b5951b13", size = 19447, upload-time = "2025-12-17T23:31:30.181Z" }, + { url = "https://files.pythonhosted.org/packages/fa/14/d71ce771712e1cbfa15d8c24452225109262b16cb6caaf967e9f60662b67/time_machine-3.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:161bbd0648802ffdfcb4bb297ecb26b3009684a47d3a4dedb90bc549df4fa2ad", size = 15432, upload-time = "2025-12-17T23:31:31.381Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d6/dcb43a11f8029561996fad58ff9d3dc5e6d7f32b74f0745a2965d7e4b4f3/time_machine-3.2.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1359ba8c258be695ba69253bc84db882fd616fe69b426cc6056536da2c7bf68e", size = 32956, upload-time = "2025-12-17T23:31:32.469Z" }, + { url = "https://files.pythonhosted.org/packages/77/da/d802cd3c335c414f9b11b479f7459aa72df5de6485c799966cfdf8856d53/time_machine-3.2.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c85b169998ca2c24a78fb214586ec11c4cad56d9c38f55ad8326235cb481c884", size = 34556, upload-time = "2025-12-17T23:31:33.946Z" }, + { url = "https://files.pythonhosted.org/packages/85/ee/51ad553514ab0b940c7c82c6e1519dd10fd06ac07b32039a1d153ef09c88/time_machine-3.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65b9367cb8a10505bc8f67da0da514ba20fa816fc47e11f434f7c60350322b4c", size = 36101, upload-time = "2025-12-17T23:31:35.462Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/938b111b5bb85a2b07502d0f9d8a704fc75bd760d62e76bce23c89ed16c9/time_machine-3.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9faca6a0f1973d7df3233c951fc2a11ff0c54df74087d8aaf41ae3deb19d0893", size = 34905, upload-time = "2025-12-17T23:31:36.543Z" }, + { url = "https://files.pythonhosted.org/packages/dd/50/0951f73b23e76455de0b4a3a58ac5a24bd8d10489624b1c5e03f10c6fc0b/time_machine-3.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:213b1ada7f385d467e598999b642eda4a8e89ae10ad5dc4f5d8f672cbf604261", size = 33012, upload-time = "2025-12-17T23:31:37.967Z" }, + { url = "https://files.pythonhosted.org/packages/4f/95/5304912d3dcecc4e14ed222dbe0396352efdf8497534abc3c9edd67a7528/time_machine-3.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:160b6afd94c39855af04d39c58e4cf602406abd6d79427ab80e830ea71789cfb", size = 34104, upload-time = "2025-12-17T23:31:39.449Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/af56518652ec7adac4ced193b7a42c4ff354fef28a412b3b5ffa5763aead/time_machine-3.2.0-cp310-cp310-win32.whl", hash = "sha256:c15d9ac257c78c124d112e4fc91fa9f3dcb004bdda913c19f0e7368d713cf080", size = 17468, upload-time = "2025-12-17T23:31:40.432Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/0213f00ca3cf6fe1c9fdbd7fd467e801052fc85534f30c0e4684bd474190/time_machine-3.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:3bf0f428487f93b8fe9d27aa01eccc817885da3290b467341b4a4a795e1d1891", size = 18313, upload-time = "2025-12-17T23:31:41.617Z" }, + { url = "https://files.pythonhosted.org/packages/77/e4/811f96aa7a634b2b264d9a476f3400e710744dda503b4ad87a5c76db32c9/time_machine-3.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:347f6be2129fcd35b1c94b9387fcb2cbe7949b1e649228c5f22949a811b78976", size = 17037, upload-time = "2025-12-17T23:31:42.924Z" }, { url = "https://files.pythonhosted.org/packages/f5/e1/03aae5fbaa53859f665094af696338fc7cae733d926a024af69982712350/time_machine-3.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c188a9dda9fcf975022f1b325b466651b96a4dfc223c523ed7ed8d979f9bf3e8", size = 19143, upload-time = "2025-12-17T23:31:44.258Z" }, { url = "https://files.pythonhosted.org/packages/75/8f/98cb17bebb52b22ff4ec26984dd44280f9c71353c3bae0640a470e6683e5/time_machine-3.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17245f1cc2dd13f9d63a174be59bb2684a9e5e0a112ab707e37be92068cd655f", size = 15273, upload-time = "2025-12-17T23:31:45.246Z" }, { url = "https://files.pythonhosted.org/packages/dd/2f/ca11e4a7897234bb9331fcc5f4ed4714481ba4012370cc79a0ae8c42ea0a/time_machine-3.2.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9bd1de1996e76efd36ae15970206c5089fb3728356794455bd5cd8d392b5537", size = 31049, upload-time = "2025-12-17T23:31:46.613Z" }, @@ -3299,96 +1603,57 @@ wheels = [ ] [[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" }, +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, ] [[package]] @@ -3405,483 +1670,13 @@ name = "typing-inspection" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "extra == 'group-11-agentex-sdk-pydantic-v2' or extra != 'group-11-agentex-sdk-pydantic-v1'" }, ] 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/24/24/0e18177e2fbb0b9f54f90fd48fe3302dfda731e22ad650d6e6f8f4b3d3d3/uuid_utils-0.16.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:04af9966ecd82b78eeba5725e29aa1e86fb8eb84b5443dd6a9935f9fadb6678e", size = 565929, upload-time = "2026-05-19T07:44:06.496Z" }, - { url = "https://files.pythonhosted.org/packages/5a/7e/bb91b04b2c8a081a4df2d50f1a50dd85502e2391c6eaed71b339ec9f2524/uuid_utils-0.16.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3d86ca394e0ea21bdb53784eb99276d263b93d1586f56678cab1414b7ae1d0f3", size = 290556, upload-time = "2026-05-19T07:43:44.973Z" }, - { url = "https://files.pythonhosted.org/packages/69/2a/47ee18b294af59754ef5acfa96eb027137c98cef7521199b6f70be705de4/uuid_utils-0.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9f504efeb20ffd9571621658f7c8093c646d33150406d5742e49ff7cd861615", size = 328059, upload-time = "2026-05-19T07:45:30.533Z" }, - { url = "https://files.pythonhosted.org/packages/89/7c/ed6d8bb48eeecaed6722af1187d722c5243334be750419d10d5f05dffeb2/uuid_utils-0.16.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:57d85f48535dc541060f6b82f277cbcd12b78c04008ccc1039546cfcec027327", size = 334759, upload-time = "2026-05-19T07:45:07.715Z" }, - { url = "https://files.pythonhosted.org/packages/ff/33/371bddf9fd47e045c375df9668eea0d96ce9201ab6a03985b0155498e376/uuid_utils-0.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:39453f1ebf4398fbeb71607f3437e2ac469c9e38b5921755c1e17ad0158a8907", size = 448927, upload-time = "2026-05-19T07:45:11.464Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f1/b201d5ee005d4987fc072714fcb9f6e75303520cf19d4deec0b4df44bf40/uuid_utils-0.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50361aca5c2a770728a6343df85109fe57f89ac026827f34fe0153563cdc9ce7", size = 327178, upload-time = "2026-05-19T07:44:02.255Z" }, - { url = "https://files.pythonhosted.org/packages/b1/6a/04b4c02ce5c24a3602baa12e59bd3ec853ae73c3e9319b706c4620f47a05/uuid_utils-0.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:948485c47d8569a8bf6e86f522a2599fa9134674bee9f483898e601e68c3caca", size = 352981, upload-time = "2026-05-19T07:44:25.578Z" }, - { url = "https://files.pythonhosted.org/packages/2c/19/25db019727d14630c75c2a75a8ea66dd712bb468adcf410bac8d01ff19fd/uuid_utils-0.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ceef237cf8467fddbf6d8466cc1f6e2c04605ec919046ef5eba10a895b559fcf", size = 504686, upload-time = "2026-05-19T07:43:46.43Z" }, - { url = "https://files.pythonhosted.org/packages/5d/93/c000cd42ebfdd37cc74981ed31c979a1270156572bdebab8b5d61460e750/uuid_utils-0.16.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:24e6fa0d0ade7a9ad60a3c296022474983243df5b4e863babb4828a85ef2e52c", size = 610102, upload-time = "2026-05-19T07:45:53.765Z" }, - { url = "https://files.pythonhosted.org/packages/15/1d/7dd239909c82616722b9ee53fa1b4657c6244fb4fd026890300ebf6db22b/uuid_utils-0.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1c2df42314b014c9d23330f92887e21d2fc72fde0beb170c7833cd2d22d845a1", size = 569048, upload-time = "2026-05-19T07:45:41.596Z" }, - { url = "https://files.pythonhosted.org/packages/f1/49/b6a688648368a9cc0137e183657956853a91dc06ef73deda27290d586155/uuid_utils-0.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2e2f369dd734050fe96ae4905c58779b09276d47d5e9a0e5cd33ec7982784341", size = 532255, upload-time = "2026-05-19T07:45:16.936Z" }, - { url = "https://files.pythonhosted.org/packages/3f/fb/34f221ae93d5ea249a0d7056bdf45313b8d267d6aa9c5d0673ac1a4746c7/uuid_utils-0.16.0-cp311-cp311-win32.whl", hash = "sha256:733da81d51ea578862d8b9b754e8968b6da2be2b7840aee868917c23cae84015", size = 171081, upload-time = "2026-05-19T07:45:26.578Z" }, - { url = "https://files.pythonhosted.org/packages/a5/70/c2a608a813f655834ee6df4ce53ea46edad4d54f774eac1890be5c7e4e1c/uuid_utils-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:10d21fddb086e69245c4f0f77c7b442471f3a242aa85f62954bff157baa1c5f2", size = 176770, upload-time = "2026-05-19T07:43:49.102Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c3/8ab4eff328a833c065f280b2e0d9ac873505b5e5282f2bc5133a9843d4dd/uuid_utils-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:98e2404713677070cee9a99a1f1e24afd496c18e833ee1b31a0587659452ff80", size = 175274, upload-time = "2026-05-19T07:44:27.216Z" }, - { 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" }, - { url = "https://files.pythonhosted.org/packages/d3/89/655408a5485c56bf2c4561eb85f5bca119b1f4020370b4daaeb8d13e46fb/uuid_utils-0.16.0-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:4e35e9a986e86806a61288fac3afbb51317f2580929feefd1661891ffd7b8c24", size = 569295, upload-time = "2026-05-19T07:45:22.325Z" }, - { url = "https://files.pythonhosted.org/packages/24/1c/a7c5506a4e2cf95ac98fec0996c56daa14e41f2ab1858f569b3556a202f9/uuid_utils-0.16.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b35706350cf9bd4813f1811bebe03cac09795a5a379f90cb3616171f4e9ffc9e", size = 292316, upload-time = "2026-05-19T07:43:57.044Z" }, - { url = "https://files.pythonhosted.org/packages/dd/75/4267ab8baa1e6a8ad7c262e204484b44df0fde0920025ea9b43c2b869726/uuid_utils-0.16.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4fd5c7936a876ba2606ba124603b559a5c2cea458c59b9c31677e6acc3c53cc", size = 329619, upload-time = "2026-05-19T07:44:12.928Z" }, - { url = "https://files.pythonhosted.org/packages/15/77/c794102831e331564f651099cac55006694677938d70f1033b35da451a89/uuid_utils-0.16.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:130f7452c1b87b7c16d0bdc1f32a1de531ae4cc4220ed4e691402bbcfc39e0a9", size = 335121, upload-time = "2026-05-19T07:45:47.974Z" }, - { url = "https://files.pythonhosted.org/packages/8b/3e/458a0a2da75c596b151182a6c7550c6c3d30f479e14e40f69c0336579e59/uuid_utils-0.16.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5ee0bbbd4ca3968422cd8308f0072520bc73dc760cb26c6fa75ca1aca14d210", size = 449631, upload-time = "2026-05-19T07:45:50.645Z" }, - { url = "https://files.pythonhosted.org/packages/ed/15/dd1fab6f7fcd15f2c331d0c1f0f516bb1113a640216460f82be53db3dcf8/uuid_utils-0.16.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc0824a31898ef46a9d84d748c3abe27cdb615ac3773c53cc1f84fc8e66dc7c4", size = 328418, upload-time = "2026-05-19T07:44:52.38Z" }, - { url = "https://files.pythonhosted.org/packages/96/56/62dcd551b140cbeb0f87522da2015b4b9e5818327b920506ad88d28562b0/uuid_utils-0.16.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abfbf5e0c47fb31b37164a99515104e449a0bee36a071dc8b105457a2b35a5e6", size = 356177, upload-time = "2026-05-19T07:45:42.856Z" }, - { url = "https://files.pythonhosted.org/packages/44/e7/3937b9a9d6745b94dbe7b86531e098db8c53b77c8d07df7daa9577a47b8e/uuid_utils-0.16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:680799a9ade01d69c53cb9d41392ced24919d4f600bfab5060b61fca37510097", size = 178508, upload-time = "2026-05-19T07:43:43.774Z" }, -] - -[[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/85/02/366ae902cd81ca5befcd1854b5c7477b378f68861597cef854bd6dc69fbe/watchfiles-0.24.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:bdcd5538e27f188dd3c804b4a8d5f52a7fc7f87e7fd6b374b8e36a4ca03db428", size = 375579, upload-time = "2024-08-28T16:20:04.865Z" }, - { url = "https://files.pythonhosted.org/packages/bc/67/d8c9d256791fe312fea118a8a051411337c948101a24586e2df237507976/watchfiles-0.24.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2dadf8a8014fde6addfd3c379e6ed1a981c8f0a48292d662e27cabfe4239c83c", size = 367726, upload-time = "2024-08-28T16:20:06.111Z" }, - { url = "https://files.pythonhosted.org/packages/b1/dc/a8427b21ef46386adf824a9fec4be9d16a475b850616cfd98cf09a97a2ef/watchfiles-0.24.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6509ed3f467b79d95fc62a98229f79b1a60d1b93f101e1c61d10c95a46a84f43", size = 437735, upload-time = "2024-08-28T16:20:07.547Z" }, - { url = "https://files.pythonhosted.org/packages/3a/21/0b20bef581a9fbfef290a822c8be645432ceb05fb0741bf3c032e0d90d9a/watchfiles-0.24.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8360f7314a070c30e4c976b183d1d8d1585a4a50c5cb603f431cebcbb4f66327", size = 433644, upload-time = "2024-08-28T16:20:09.15Z" }, - { url = "https://files.pythonhosted.org/packages/1c/e8/d5e5f71cc443c85a72e70b24269a30e529227986096abe091040d6358ea9/watchfiles-0.24.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:316449aefacf40147a9efaf3bd7c9bdd35aaba9ac5d708bd1eb5763c9a02bef5", size = 450928, upload-time = "2024-08-28T16:20:11.152Z" }, - { url = "https://files.pythonhosted.org/packages/61/ee/bf17f5a370c2fcff49e1fec987a6a43fd798d8427ea754ce45b38f9e117a/watchfiles-0.24.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73bde715f940bea845a95247ea3e5eb17769ba1010efdc938ffcb967c634fa61", size = 469072, upload-time = "2024-08-28T16:20:12.345Z" }, - { url = "https://files.pythonhosted.org/packages/a3/34/03b66d425986de3fc6077e74a74c78da298f8cb598887f664a4485e55543/watchfiles-0.24.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3770e260b18e7f4e576edca4c0a639f704088602e0bc921c5c2e721e3acb8d15", size = 475517, upload-time = "2024-08-28T16:20:13.555Z" }, - { url = "https://files.pythonhosted.org/packages/70/eb/82f089c4f44b3171ad87a1b433abb4696f18eb67292909630d886e073abe/watchfiles-0.24.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa0fd7248cf533c259e59dc593a60973a73e881162b1a2f73360547132742823", size = 425480, upload-time = "2024-08-28T16:20:15.037Z" }, - { url = "https://files.pythonhosted.org/packages/53/20/20509c8f5291e14e8a13104b1808cd7cf5c44acd5feaecb427a49d387774/watchfiles-0.24.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d7a2e3b7f5703ffbd500dabdefcbc9eafeff4b9444bbdd5d83d79eedf8428fab", size = 612322, upload-time = "2024-08-28T16:20:16.095Z" }, - { url = "https://files.pythonhosted.org/packages/df/2b/5f65014a8cecc0a120f5587722068a975a692cadbe9fe4ea56b3d8e43f14/watchfiles-0.24.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d831ee0a50946d24a53821819b2327d5751b0c938b12c0653ea5be7dea9c82ec", size = 595094, upload-time = "2024-08-28T16:20:17.395Z" }, - { url = "https://files.pythonhosted.org/packages/18/98/006d8043a82c0a09d282d669c88e587b3a05cabdd7f4900e402250a249ac/watchfiles-0.24.0-cp311-none-win32.whl", hash = "sha256:49d617df841a63b4445790a254013aea2120357ccacbed00253f9c2b5dc24e2d", size = 264191, upload-time = "2024-08-28T16:20:18.472Z" }, - { url = "https://files.pythonhosted.org/packages/8a/8b/badd9247d6ec25f5f634a9b3d0d92e39c045824ec7e8afcedca8ee52c1e2/watchfiles-0.24.0-cp311-none-win_amd64.whl", hash = "sha256:d3dcb774e3568477275cc76554b5a565024b8ba3a0322f77c246bc7111c5bb9c", size = 277527, upload-time = "2024-08-28T16:20:20.096Z" }, - { url = "https://files.pythonhosted.org/packages/af/19/35c957c84ee69d904299a38bae3614f7cede45f07f174f6d5a2f4dbd6033/watchfiles-0.24.0-cp311-none-win_arm64.whl", hash = "sha256:9301c689051a4857d5b10777da23fafb8e8e921bcf3abe6448a058d27fb67633", size = 266253, upload-time = "2024-08-28T16:20:21.381Z" }, - { 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/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, - { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, - { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, - { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, - { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, - { 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/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, - { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, - { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, - { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, - { 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/5f/ac/4370bde262c0e633e6c4f0e56d55095710024cf9a5cecc20c59a10de483c/wrapt-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dd57607acc85678925940bd5df0385ff8332083a32fa8d7a43f8767f4997263c", size = 80321, upload-time = "2026-05-22T14:47:43.996Z" }, - { url = "https://files.pythonhosted.org/packages/eb/79/b8ff3a61e71babf58a8cf4c0d63358e8bad383e15bf7f35e62d2f6b6e4a4/wrapt-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ae574d65c9fa8e86f64f6a7c2668f9fcd507b183e0e577619f504b883cb0a6c", size = 81216, upload-time = "2026-05-22T14:47:45.243Z" }, - { url = "https://files.pythonhosted.org/packages/6e/fd/c0cac1f77c9c4f6fe58a920ca632ce379bb8be928720e11e8d73de28a5e9/wrapt-2.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a04c28c10ba7fd12842b109d2edb0678872a2fe65277ca4ff06a0d61edee245", size = 159208, upload-time = "2026-05-22T14:47:47.176Z" }, - { url = "https://files.pythonhosted.org/packages/d9/4f/744132a7b2fbefa6b81118ec5942eca5fc2e9a129f9055a0c5e46885a549/wrapt-2.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e2f02472a1cbbf3884b365714a810b5947134a95ad6952b554cb8cce9d492b0", size = 160322, upload-time = "2026-05-22T14:47:49.04Z" }, - { url = "https://files.pythonhosted.org/packages/d6/95/b7cd9a22a06cf93e6482904ee6afc956248983553593fd1009296d1b3b31/wrapt-2.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac2745950b2bff80219c15ebf2fa9d8427eba7e249739f97e55c9d169e47e9e1", size = 153243, upload-time = "2026-05-22T14:47:50.386Z" }, - { url = "https://files.pythonhosted.org/packages/4c/4a/eb79423192015f46f0db2872e7e04a3dde8d359b83411e8959e7c9287eaa/wrapt-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67a97e5b6c457f0cd3cfc19ebb2d84463e60c3ece754cc831e4281a3ca29bb18", size = 159231, upload-time = "2026-05-22T14:47:51.753Z" }, - { url = "https://files.pythonhosted.org/packages/ec/dc/435015b58ce33c6fc4104158fa91ddb0e809ab03a5751fb7465d1d461456/wrapt-2.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c803a3d331796255af51ba2c79ed0ac8275865b516c09e61f248d1e7aff31ce9", size = 152351, upload-time = "2026-05-22T14:47:53.214Z" }, - { url = "https://files.pythonhosted.org/packages/77/ac/5d203f98df8fd136b95c5227139aea02d34505e18baf812d0c005df61963/wrapt-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9b984d1eb252145d6302c1dbd5e87fc6d404d45531447c84eadec04bf1fcb027", size = 158347, upload-time = "2026-05-22T14:47:54.982Z" }, - { url = "https://files.pythonhosted.org/packages/52/2f/a92427dbdc74e54c1674abbed27e61b2cb5e7a94441b8c1270c70671d928/wrapt-2.2.1-cp311-cp311-win32.whl", hash = "sha256:8a983a603a18c8708f024f7f6991b2e66159219abbf894634c5056243c55f3cd", size = 77562, upload-time = "2026-05-22T14:47:56.275Z" }, - { url = "https://files.pythonhosted.org/packages/c8/56/987b9c13b3e1c1a3c6de71284076f996b79caec90e75a87c044a40c23db9/wrapt-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:9c210a6994b21aa9b29e81c8d11560e8fdab54c117e9cff37870d0a27bde1343", size = 80616, upload-time = "2026-05-22T14:47:57.854Z" }, - { url = "https://files.pythonhosted.org/packages/7e/25/d01f560888d99d94a959c85533de349ce68d71ace3f2591d6ea8f632cfed/wrapt-2.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:401229e9d63ca09f9b8891ecf83798d26c11bbb445d11ed9f1836b6d4585b38a", size = 79025, upload-time = "2026-05-22T14:47:59.089Z" }, - { 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/3b/f4/7bd35089ff1f8e2c96baa2dce05775a122aacd2e3830a73165e27a4d0848/xxhash-3.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fdc7d06929ae28dda98297a18eef7b0fd38991a3b405d8d7b55c9ef24c296958", size = 33423, upload-time = "2026-04-25T11:05:47.628Z" }, - { url = "https://files.pythonhosted.org/packages/a3/26/4e00c88a6a2c8a759cfb77d2a9a405f901e8aa66e60ef1fd0aeb35edda48/xxhash-3.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea6daa712f4e094a30830cf01e9b47d03b24d05cc9dab8609f0d9a9db8454712", size = 30857, upload-time = "2026-04-25T11:05:49.189Z" }, - { url = "https://files.pythonhosted.org/packages/82/2f/eeb942c17a5a761a8f01cb9180a0b76bfb62a2c39e6f46b1f9001899027a/xxhash-3.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9e6c0d843f1daf85ea23aeb053579135552bde575b7b98af20bfc667b6e4548d", size = 194702, upload-time = "2026-04-25T11:05:50.457Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fd/96f132c08b1e5951c68691d3b9ec351ec2edc028f6a01fcd294f46b9d9f0/xxhash-3.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:363c139bf15e1ac5f136b981d3c077eb551299b1effede7f12faa010b8590a60", size = 213613, upload-time = "2026-04-25T11:05:52.571Z" }, - { url = "https://files.pythonhosted.org/packages/82/89/d4e92b796c5ed052d29ed324dbfc1dc1188e0c4bf64bebbf0f8fc20698df/xxhash-3.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a778b25874cb0f862eaab5986bff4ca49ffb0def7c0a34c237b948b3c6c775b2", size = 236726, upload-time = "2026-04-25T11:05:54.395Z" }, - { url = "https://files.pythonhosted.org/packages/40/f1/81fc4361921dc6e557a9c60cb3712f36d244d06eeeb71cd2f4252ac42678/xxhash-3.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e1860f1e43d40e9d904cf22d93e587ea42e010ebce4160877e46bcab4bc232a", size = 212443, upload-time = "2026-04-25T11:05:56.334Z" }, - { url = "https://files.pythonhosted.org/packages/6a/d0/afeddd4cff50a332f50d4b8a2e8857673153ab0564ef472fcdeb0b5430df/xxhash-3.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9122ad6f867c4a0f5e655f5c3bdf89103852009dbb442a3d23e688b9e699e800", size = 445793, upload-time = "2026-04-25T11:05:58.953Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d0/3c91e4e6a05ca4d7df8e39ec3a75b713609258ec84705ab34be6430826a1/xxhash-3.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d9110d0c3fb02679972837a033251fd186c529aa62f19c132fc909c74052b8", size = 193937, upload-time = "2026-04-25T11:06:00.546Z" }, - { url = "https://files.pythonhosted.org/packages/4e/3a/a6b0772d9801dd4bea4ca4fd34734d6e9b51a711c8a611a24a79de26a878/xxhash-3.7.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:347a93f2b4ce67ce61959665e32a7447c380f8347e55e100daa23766baacf0e5", size = 285188, upload-time = "2026-04-25T11:06:01.96Z" }, - { url = "https://files.pythonhosted.org/packages/6c/f8/cf8e31fd7282230fe7367cd501a2e75b4b67b222bfc7eacccfc20d2652cb/xxhash-3.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:acbb48679ddf3852c45280c10ff10d52ca2cd1da2e552fb81db1ff786c75d0e4", size = 210966, upload-time = "2026-04-25T11:06:03.453Z" }, - { url = "https://files.pythonhosted.org/packages/cc/f0/fd36cc4a81bf52ee5633275daae2b93dd958aace67fd4f5d466ec83b5f35/xxhash-3.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fe14c356f8b23ad811dc026077a6d4abccdaa7bce5ca98579605550657b6fcfb", size = 241994, upload-time = "2026-04-25T11:06:05.264Z" }, - { url = "https://files.pythonhosted.org/packages/08/e1/67f5d9c9369be42eaf99ba02c01bf14c5ecd67087b02567960bfcee43b63/xxhash-3.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f420ad3d41e38194353a498bbc9561fd5a9973a27b536ce46d8583479cf44335", size = 198707, upload-time = "2026-04-25T11:06:07.044Z" }, - { url = "https://files.pythonhosted.org/packages/50/17/a4c865ca22d2da6b1bc7d739bf88cab209533cf52ba06ca9da27c3039bee/xxhash-3.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:693d02c6dc7d1aa0a45921d54cd8c1ff629e09dfdc2238471507af1f7a1c6f04", size = 210917, upload-time = "2026-04-25T11:06:08.853Z" }, - { url = "https://files.pythonhosted.org/packages/49/8b/453b35810d697abac3c96bde3528bece685869227da274eb80a4a4d4a119/xxhash-3.7.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:14bf7a54e43825ec131ee7fe3c60e142e7c2c1e676ad0f93fc893432d15414af", size = 275772, upload-time = "2026-04-25T11:06:10.645Z" }, - { url = "https://files.pythonhosted.org/packages/b5/ad/4eed7eab07fd3ee6678f416190f0413d097ab5d7c1278906bf1e9549d789/xxhash-3.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ae3a39a4d96bdb6f8d154fd7f490c4ad06f0532fcd2bb656052a9a7762cf5d31", size = 414068, upload-time = "2026-04-25T11:06:12.511Z" }, - { url = "https://files.pythonhosted.org/packages/d3/4e/fd6f8a680ba248fdb83054fa71a8bfa3891225200de1708b888ef2c49829/xxhash-3.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1cc07c639e3a77ef1d32987464d3e408565b8a3be57b545d3542b191054d9923", size = 191459, upload-time = "2026-04-25T11:06:14.07Z" }, - { url = "https://files.pythonhosted.org/packages/50/7c/8cb34b3bed4f44ca6827a534d50833f9bc6c006e83b0eb410ac9fa0793bd/xxhash-3.7.0-cp311-cp311-win32.whl", hash = "sha256:3281ba1d1e60ee7a382a7b958513ba03c2c0d5fcbd9a6f7517c0a81251a23422", size = 30628, upload-time = "2026-04-25T11:06:15.802Z" }, - { url = "https://files.pythonhosted.org/packages/0b/47/a49767bd7b40782bedae9ff0721bfe1d7e4dd9dc1585dea684e57ba67c20/xxhash-3.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:a7f25baec4c5d851d40718d6fae52285b31683093d4ff5207e63ab306ccf14a5", size = 31461, upload-time = "2026-04-25T11:06:17.104Z" }, - { url = "https://files.pythonhosted.org/packages/7c/c6/3957bfacfb706bd687be246dfa8dd60f8df97c44186d229f7fd6e26c4b7e/xxhash-3.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:4c2454448ce847c72635827bb75c15c5a3434b03ee1afd28cb6dc6fb2597d830", size = 27746, upload-time = "2026-04-25T11:06:18.716Z" }, - { 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" }, - { url = "https://files.pythonhosted.org/packages/54/c1/e57ac7317b1f58a92bab692da6d497e2a7ce44735b224e296347a7ecc754/xxhash-3.7.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ad3aa71e12ee634f22b39a0ff439357583706e50765f17f05550f92dbf128a23", size = 31232, upload-time = "2026-04-25T11:10:21.51Z" }, - { url = "https://files.pythonhosted.org/packages/4f/4e/075559bd712bc62e84915ea46bbee859f935d285659082c129bdbff679dd/xxhash-3.7.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5de686e73690cdaf72b96d4fa083c230ec9020bcc2627ce6316138e2cf2fe2d1", size = 28553, upload-time = "2026-04-25T11:10:23.1Z" }, - { url = "https://files.pythonhosted.org/packages/92/ca/a9c78cb384d4b033b0c58196bd5c8509873cabe76389e195127b0302a741/xxhash-3.7.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7fbec49f5341bbdea0c471f7d1e2fb41ae8925af9b6f28025c28defd8eb94274", size = 41109, upload-time = "2026-04-25T11:10:25.022Z" }, - { url = "https://files.pythonhosted.org/packages/bd/b1/dfe2629f7c77eb2fa234c72ff537cdd64939763df704e256446ed364a16d/xxhash-3.7.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48b542c347c2089f43dc5a6db31d2a6f3cdb04ee33505ec6e9f653834dbb0bde", size = 36307, upload-time = "2026-04-25T11:10:26.949Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f7/5a484afce0f48dd8083208b42e4911f290a82c7b52458ef2927e4d421a45/xxhash-3.7.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a169a036bed0995e090d1493b283cc2cc8a6f5046821086b843abefff80643bc", size = 32534, upload-time = "2026-04-25T11:10:29.01Z" }, - { url = "https://files.pythonhosted.org/packages/0f/5f/4acfcd490db9780cf36c58534d828003c564cde5350220a1c783c4d10776/xxhash-3.7.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ec101643395d7f21405b640f728f6f627e6986557027d740f2f9b220955edafe", size = 31552, upload-time = "2026-04-25T11:10:30.727Z" }, -] - [[package]] name = "yarl" version = "1.22.0" @@ -3893,6 +1688,22 @@ dependencies = [ ] 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/d1/43/a2204825342f37c337f5edb6637040fa14e365b2fcc2346960201d457579/yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e", size = 140517, upload-time = "2025-10-06T14:08:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/44/6f/674f3e6f02266428c56f704cd2501c22f78e8b2eeb23f153117cc86fb28a/yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f", size = 93495, upload-time = "2025-10-06T14:08:46.2Z" }, + { url = "https://files.pythonhosted.org/packages/b8/12/5b274d8a0f30c07b91b2f02cba69152600b47830fcfb465c108880fcee9c/yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf", size = 94400, upload-time = "2025-10-06T14:08:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7f/df1b6949b1fa1aa9ff6de6e2631876ad4b73c4437822026e85d8acb56bb1/yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a", size = 347545, upload-time = "2025-10-06T14:08:49.683Z" }, + { url = "https://files.pythonhosted.org/packages/84/09/f92ed93bd6cd77872ab6c3462df45ca45cd058d8f1d0c9b4f54c1704429f/yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c", size = 319598, upload-time = "2025-10-06T14:08:51.215Z" }, + { url = "https://files.pythonhosted.org/packages/c3/97/ac3f3feae7d522cf7ccec3d340bb0b2b61c56cb9767923df62a135092c6b/yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147", size = 363893, upload-time = "2025-10-06T14:08:53.144Z" }, + { url = "https://files.pythonhosted.org/packages/06/49/f3219097403b9c84a4d079b1d7bda62dd9b86d0d6e4428c02d46ab2c77fc/yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb", size = 371240, upload-time = "2025-10-06T14:08:55.036Z" }, + { url = "https://files.pythonhosted.org/packages/35/9f/06b765d45c0e44e8ecf0fe15c9eacbbde342bb5b7561c46944f107bfb6c3/yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6", size = 346965, upload-time = "2025-10-06T14:08:56.722Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/599e7cea8d0fcb1694323b0db0dda317fa3162f7b90166faddecf532166f/yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0", size = 342026, upload-time = "2025-10-06T14:08:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/95/6f/9dfd12c8bc90fea9eab39832ee32ea48f8e53d1256252a77b710c065c89f/yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda", size = 335637, upload-time = "2025-10-06T14:09:00.506Z" }, + { url = "https://files.pythonhosted.org/packages/57/2e/34c5b4eb9b07e16e873db5b182c71e5f06f9b5af388cdaa97736d79dd9a6/yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc", size = 359082, upload-time = "2025-10-06T14:09:01.936Z" }, + { url = "https://files.pythonhosted.org/packages/31/71/fa7e10fb772d273aa1f096ecb8ab8594117822f683bab7d2c5a89914c92a/yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737", size = 357811, upload-time = "2025-10-06T14:09:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/26/da/11374c04e8e1184a6a03cf9c8f5688d3e5cec83ed6f31ad3481b3207f709/yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467", size = 351223, upload-time = "2025-10-06T14:09:05.401Z" }, + { url = "https://files.pythonhosted.org/packages/82/8f/e2d01f161b0c034a30410e375e191a5d27608c1f8693bab1a08b089ca096/yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea", size = 82118, upload-time = "2025-10-06T14:09:11.148Z" }, + { url = "https://files.pythonhosted.org/packages/62/46/94c76196642dbeae634c7a61ba3da88cd77bed875bf6e4a8bed037505aa6/yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca", size = 86852, upload-time = "2025-10-06T14:09:12.958Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/7df4f179d3b1a6dcb9a4bd2ffbc67642746fcafdb62580e66876ce83fff4/yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b", size = 82012, upload-time = "2025-10-06T14:09:14.664Z" }, { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" }, { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" }, { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" }, @@ -3989,21 +1800,25 @@ wheels = [ { 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/94/fd/6480106702a79bcceda5fd9c63cb19a04a6506bd5ce7fd8d9b63742f0021/yarl-1.22.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3aa27acb6de7a23785d81557577491f6c38a5209a254d1191519d07d8fe51748", size = 141301, upload-time = "2025-10-06T14:12:19.01Z" }, + { url = "https://files.pythonhosted.org/packages/42/e1/6d95d21b17a93e793e4ec420a925fe1f6a9342338ca7a563ed21129c0990/yarl-1.22.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:af74f05666a5e531289cb1cc9c883d1de2088b8e5b4de48004e5ca8a830ac859", size = 93864, upload-time = "2025-10-06T14:12:21.05Z" }, + { url = "https://files.pythonhosted.org/packages/32/58/b8055273c203968e89808413ea4c984988b6649baabf10f4522e67c22d2f/yarl-1.22.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:62441e55958977b8167b2709c164c91a6363e25da322d87ae6dd9c6019ceecf9", size = 94706, upload-time = "2025-10-06T14:12:23.287Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/d7bfbc28a88c2895ecd0da6a874def0c147de78afc52c773c28e1aa233a3/yarl-1.22.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b580e71cac3f8113d3135888770903eaf2f507e9421e5697d6ee6d8cd1c7f054", size = 347100, upload-time = "2025-10-06T14:12:28.527Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e8/37a1e7b99721c0564b1fc7b0a4d1f595ef6fb8060d82ca61775b644185f7/yarl-1.22.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e81fda2fb4a07eda1a2252b216aa0df23ebcd4d584894e9612e80999a78fd95b", size = 318902, upload-time = "2025-10-06T14:12:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ef/34724449d7ef2db4f22df644f2dac0b8a275d20f585e526937b3ae47b02d/yarl-1.22.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99b6fc1d55782461b78221e95fc357b47ad98b041e8e20f47c1411d0aacddc60", size = 363302, upload-time = "2025-10-06T14:12:32.295Z" }, + { url = "https://files.pythonhosted.org/packages/8a/04/88a39a5dad39889f192cce8d66cc4c58dbeca983e83f9b6bf23822a7ed91/yarl-1.22.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:088e4e08f033db4be2ccd1f34cf29fe994772fb54cfe004bbf54db320af56890", size = 370816, upload-time = "2025-10-06T14:12:34.01Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1f/5e895e547129413f56c76be2c3ce4b96c797d2d0ff3e16a817d9269b12e6/yarl-1.22.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4e1f6f0b4da23e61188676e3ed027ef0baa833a2e633c29ff8530800edccba", size = 346465, upload-time = "2025-10-06T14:12:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/11/13/a750e9fd6f9cc9ed3a52a70fe58ffe505322f0efe0d48e1fd9ffe53281f5/yarl-1.22.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:84fc3ec96fce86ce5aa305eb4aa9358279d1aa644b71fab7b8ed33fe3ba1a7ca", size = 341506, upload-time = "2025-10-06T14:12:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/3c/67/bb6024de76e7186611ebe626aec5b71a2d2ecf9453e795f2dbd80614784c/yarl-1.22.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5dbeefd6ca588b33576a01b0ad58aa934bc1b41ef89dee505bf2932b22ddffba", size = 335030, upload-time = "2025-10-06T14:12:39.775Z" }, + { url = "https://files.pythonhosted.org/packages/a2/be/50b38447fd94a7992996a62b8b463d0579323fcfc08c61bdba949eef8a5d/yarl-1.22.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14291620375b1060613f4aab9ebf21850058b6b1b438f386cc814813d901c60b", size = 358560, upload-time = "2025-10-06T14:12:41.547Z" }, + { url = "https://files.pythonhosted.org/packages/e2/89/c020b6f547578c4e3dbb6335bf918f26e2f34ad0d1e515d72fd33ac0c635/yarl-1.22.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:a4fcfc8eb2c34148c118dfa02e6427ca278bfd0f3df7c5f99e33d2c0e81eae3e", size = 357290, upload-time = "2025-10-06T14:12:43.861Z" }, + { url = "https://files.pythonhosted.org/packages/8c/52/c49a619ee35a402fa3a7019a4fa8d26878fec0d1243f6968bbf516789578/yarl-1.22.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:029866bde8d7b0878b9c160e72305bbf0a7342bcd20b9999381704ae03308dc8", size = 350700, upload-time = "2025-10-06T14:12:46.868Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c9/f5042d87777bf6968435f04a2bbb15466b2f142e6e47fa4f34d1a3f32f0c/yarl-1.22.0-cp39-cp39-win32.whl", hash = "sha256:4dcc74149ccc8bba31ce1944acee24813e93cfdee2acda3c172df844948ddf7b", size = 82323, upload-time = "2025-10-06T14:12:48.633Z" }, + { url = "https://files.pythonhosted.org/packages/fd/58/d00f7cad9eba20c4eefac2682f34661d1d1b3a942fc0092eb60e78cfb733/yarl-1.22.0-cp39-cp39-win_amd64.whl", hash = "sha256:10619d9fdee46d20edc49d3479e2f8269d0779f1b031e6f7c2aa1c76be04b7ed", size = 87145, upload-time = "2025-10-06T14:12:50.241Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a3/70904f365080780d38b919edd42d224b8c4ce224a86950d2eaa2a24366ad/yarl-1.22.0-cp39-cp39-win_arm64.whl", hash = "sha256:dd7afd3f8b0bfb4e0d9fc3c31bfe8a4ec7debe124cfd90619305def3c8ca8cd2", size = 82173, upload-time = "2025-10-06T14:12:51.869Z" }, { 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" @@ -4012,77 +1827,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50e 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/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, - { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, - { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" }, - { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" }, - { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" }, - { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" }, - { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" }, - { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" }, - { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" }, - { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" }, - { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" }, - { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" }, - { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" }, - { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" }, - { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" }, - { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" }, - { 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" }, -]