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 41b495d71..000000000 --- a/.github/workflows/agentex-tutorials-test.yml +++ /dev/null @@ -1,363 +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 - - # Subprocess-CLI harnesses: install the relevant CLI only for the - # claude-code / codex tutorials (no-op for every other tutorial). npm is - # preinstalled on ubuntu runners. Versions mirror the golden agent's - # sandbox image (teams/sgp/agents/golden_agent/sandbox/Dockerfile): claude-code - # is pinned to the same CLAUDE_CODE_VERSION; codex is left unpinned there, - # so it is left unpinned here too. Bump CLAUDE_CODE_VERSION in lockstep - # with the sandbox Dockerfile. - - name: Install harness CLI (claude-code / codex only) - if: ${{ contains(matrix.tutorial, 'claude_code') || contains(matrix.tutorial, 'codex') }} - env: - CLAUDE_CODE_VERSION: "2.1.142" - run: | - if [[ "${{ matrix.tutorial }}" == *claude_code* ]]; then - echo "๐Ÿ“ฆ Installing Claude Code CLI (v${CLAUDE_CODE_VERSION})..." - npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" - claude --version || true - fi - if [[ "${{ matrix.tutorial }}" == *codex* ]]; then - echo "๐Ÿ“ฆ Installing Codex CLI..." - npm install -g @openai/codex - codex --version || true - fi - - - name: Pull latest AgentEx image - run: | - echo "๐Ÿณ Pulling latest Scale AgentEx Docker image..." - max_attempts=3 - attempt=1 - while [ $attempt -le $max_attempts ]; do - echo "Attempt $attempt of $max_attempts..." - if docker pull ghcr.io/scaleapi/scale-agentex/agentex:latest; then - echo "โœ… Successfully pulled AgentEx Docker image" - exit 0 - fi - echo "โŒ Pull failed, waiting before retry..." - sleep $((attempt * 10)) - attempt=$((attempt + 1)) - done - echo "โŒ Failed to pull image after $max_attempts attempts" - exit 1 - - - name: Checkout scale-agentex repo - uses: actions/checkout@v4 - with: - repository: scaleapi/scale-agentex - path: scale-agentex - - - name: Configure Docker Compose for pulled image and host networking - run: | - cd scale-agentex/agentex - echo "๐Ÿ”ง Configuring AgentEx container to use pulled image and host networking..." - - # Install yq for YAML manipulation - sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 - sudo chmod +x /usr/local/bin/yq - - # Override to use pulled image instead of building - yq eval '.services.agentex.image = "ghcr.io/scaleapi/scale-agentex/agentex:latest"' -i docker-compose.yml - yq eval 'del(.services.agentex.build)' -i docker-compose.yml - - # Add extra_hosts to agentex service to make host.docker.internal work - yq eval '.services.agentex.extra_hosts = ["host.docker.internal:host-gateway"]' -i docker-compose.yml - - echo "โœ… Configured docker-compose to use pulled image with host access" - - - name: Start AgentEx Server - run: | - cd scale-agentex/agentex - echo "๐Ÿš€ Starting AgentEx server and dependencies..." - - # Start all services - docker compose up -d - - echo "โณ Waiting for dependencies to be healthy..." - - # Wait for services to be healthy - for i in {1..30}; do - if docker compose ps | grep -q "healthy"; then - echo "โœ… Dependencies are healthy" - break - fi - echo " Attempt $i/30: Waiting for services..." - sleep 5 - done - - # Wait specifically for AgentEx server to be ready - echo "โณ Waiting for AgentEx server to be ready..." - for i in {1..30}; do - if curl -s --max-time 5 http://localhost:5003/health >/dev/null 2>&1; then - echo "โœ… AgentEx server is ready" - break - fi - echo " Attempt $i/30: Waiting for AgentEx server..." - sleep 5 - done - - - name: Build AgentEx SDK - run: | - echo "๐Ÿ”จ Building both SDK wheels (slim client + heavy ADK overlay)..." - # uv workspace builds both members into the root dist/. --wheel: the - # heavy's cross-dir force-include can't build via the sdist default. - uv build --all-packages --wheel - echo "โœ… Both SDK wheels built successfully" - ls -la dist/ - - - name: Test Tutorial - id: run-test - working-directory: ./examples/tutorials - env: - OPENAI_API_KEY: ${{ secrets.TUTORIAL_OPENAI_API_KEY }} - ANTHROPIC_API_KEY: ${{ secrets.TUTORIAL_ANTHROPIC_API_KEY }} - # Enable the gated live tests only for the matching subprocess-CLI - # harness tutorial (the CLI is installed for it in the step above). - CLAUDE_LIVE_TESTS: ${{ contains(matrix.tutorial, 'claude_code') && '1' || '' }} - CODEX_LIVE_TESTS: ${{ contains(matrix.tutorial, 'codex') && '1' || '' }} - HEALTH_CHECK_PORT: 8080 # Use non-privileged port for temporal worker health checks - run: | - echo "Testing tutorial: ${{ matrix.tutorial }}" - AGENTEX_API_BASE_URL="http://localhost:5003" \ - ./run_agent_test.sh --build-cli "${{ matrix.tutorial }}" - - - name: Print agent logs on failure - if: failure() - working-directory: ./examples/tutorials - run: | - echo "๐Ÿšจ Test failed for tutorial: ${{ matrix.tutorial }}" - - # Print agent logs from /tmp (where run_agent_test.sh writes them) - tutorial_name=$(basename "${{ matrix.tutorial }}") - agent_log="/tmp/agentex-${tutorial_name}.log" - if [[ -f "$agent_log" ]]; then - echo "๐Ÿ“‹ Agent logs ($agent_log):" - echo "----------------------------------------" - tail -100 "$agent_log" - echo "----------------------------------------" - else - echo "โš ๏ธ No agent log at $agent_log" - echo "Available /tmp/agentex-*.log files:" - ls -la /tmp/agentex-*.log 2>/dev/null || echo " (none)" - fi - - # Print Docker server logs - echo "" - echo "๐Ÿ“‹ AgentEx Server (Docker) logs:" - echo "----------------------------------------" - cd ../../scale-agentex/agentex && docker compose logs --tail=100 agentex 2>/dev/null || echo "Could not retrieve Docker logs" - echo "----------------------------------------" - - echo "" - echo "๐Ÿ” Running python processes:" - ps aux | grep python || echo "No python processes found" - - - name: Record test result - id: test-result - if: always() - run: | - # Create results directory - mkdir -p test-results - - # Determine result - if [ "${{ steps.run-test.outcome }}" == "success" ]; then - result="passed" - echo "result=passed" >> $GITHUB_OUTPUT - echo "tutorial=${{ matrix.tutorial }}" >> $GITHUB_OUTPUT - else - result="failed" - echo "result=failed" >> $GITHUB_OUTPUT - echo "tutorial=${{ matrix.tutorial }}" >> $GITHUB_OUTPUT - fi - - # Save result to file for artifact upload - # Create a safe filename from tutorial path - safe_name=$(echo "${{ matrix.tutorial }}" | tr '/' '_' | tr -d ' ') - echo "$result" > "test-results/result-${safe_name}.txt" - echo "${{ matrix.tutorial }}" > "test-results/tutorial-${safe_name}.txt" - echo "safe_name=${safe_name}" >> $GITHUB_OUTPUT - - - name: Upload test result - if: always() - uses: actions/upload-artifact@v4 - with: - name: test-result-${{ steps.test-result.outputs.safe_name }} - path: test-results/ - retention-days: 1 - - test-summary: - if: always() - 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/ci.yml b/.github/workflows/ci.yml index 8c96ca356..03a8b11e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: lint: timeout-minutes: 10 name: lint - runs-on: ${{ github.repository == 'stainless-sdks/agentex-sdk-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -29,14 +29,11 @@ jobs: version: '0.10.2' - name: Install dependencies - run: uv sync --all-packages --all-extras + run: uv sync --all-extras - name: Run lints run: ./scripts/lint - - name: Check slim dependency set - run: ./scripts/check-slim-deps - build: if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') timeout-minutes: 10 @@ -44,7 +41,7 @@ jobs: permissions: contents: read id-token: write - runs-on: ${{ github.repository == 'stainless-sdks/agentex-sdk-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -54,16 +51,10 @@ jobs: version: '0.10.2' - name: Install dependencies - run: uv sync --all-packages --all-extras + run: uv sync --all-extras - name: Run build - # Both workspace members. --wheel is load-bearing: the heavy's cross-dir - # force-include can't build via the sdist-then-wheel default. - run: uv build --all-packages --wheel - - - name: Smoke-test wheel install - # Both wheels must install together into one working agentex.* namespace. - run: ./scripts/check-wheel-install + run: uv build - name: Get GitHub OIDC Token if: |- @@ -87,7 +78,7 @@ jobs: test: timeout-minutes: 10 name: test - runs-on: ${{ github.repository == 'stainless-sdks/agentex-sdk-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/harness-integration.yml b/.github/workflows/harness-integration.yml deleted file mode 100644 index ab20929a8..000000000 --- a/.github/workflows/harness-integration.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Harness Integration - -on: - push: - branches: [main] - pull_request: - paths: - - "src/agentex/lib/core/harness/**" - - "src/agentex/lib/adk/_modules/**" - - "tests/lib/core/harness/test_harness_*.py" - - ".github/workflows/harness-integration.yml" - -jobs: - conformance: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install uv - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 - with: - version: '0.10.2' - - - name: Bootstrap - run: ./scripts/bootstrap - - # Defer to scripts/test so the harness suite runs under the exact same - # invocation as the main CI test job: DEFER_PYDANTIC_BUILD=false and - # `uv run --isolated --all-packages --all-extras pytest`, across the - # min/max supported Python versions. Running `uv run pytest` directly - # would risk an all-extras-only dep passing locally but failing in CI. - - name: Conformance suite - run: ./scripts/test tests/lib/core/harness/ -v - - # Offline harness integration tests (sync / async / temporal channels) for each - # harness. These use fake streams / TestModel + fake streaming/tracing and - # require no live infrastructure. All five harnesses are now covered; the - # trigger above uses a `test_harness_*.py` glob so new suites are picked up - # automatically. - live-matrix: - runs-on: ubuntu-latest - strategy: - matrix: - harness: [pydantic_ai, langgraph, openai, claude_code, codex] - channel: [sync, async, temporal] - fail-fast: false - name: ${{ matrix.harness }}-${{ matrix.channel }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install uv - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 - with: - version: '0.10.2' - - - name: Bootstrap - run: ./scripts/bootstrap - - - name: ${{ matrix.harness }} ${{ matrix.channel }} integration tests (offline) - run: | - ./scripts/test tests/lib/core/harness/test_harness_${{ matrix.harness }}_${{ matrix.channel }}.py -v diff --git a/.github/workflows/lint-pr.yaml b/.github/workflows/lint-pr.yaml 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 b5ff5ca9b..a9ce9049e 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -4,16 +4,6 @@ name: Publish PyPI on: workflow_dispatch: - inputs: - package: - description: Package to publish - required: true - default: all - type: choice - options: - - all - - agentex-client - - agentex-sdk release: types: [published] @@ -29,19 +19,10 @@ 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: | bash ./bin/publish-pypi env: - # Heavy `agentex-sdk` package token (existing PyPI name). - AGENTEX_PYPI_TOKEN: ${{ secrets.AGENTEX_PYPI_TOKEN }} - # Slim `agentex-client` package token (new PyPI name; needs - # to be added to repo secrets when the slim is registered). - AGENTEX_CLIENT_PYPI_TOKEN: ${{ secrets.AGENTEX_CLIENT_PYPI_TOKEN }} - # Back-compat fallback โ€” used by bin/publish-pypi when the - # dedicated tokens above are unset. PYPI_TOKEN: ${{ secrets.AGENTEX_PYPI_TOKEN || secrets.PYPI_TOKEN }} - # Manual dispatches can override tag-derived package selection. - PYPI_PACKAGE: ${{ inputs.package }} diff --git a/.gitignore b/.gitignore index e4f254d86..3824f4c48 100644 --- a/.gitignore +++ b/.gitignore @@ -14,11 +14,3 @@ dist .envrc codegen.log Brewfile.lock.json - -.DS_Store - -# Claude workspace directories -.claude-workspace/ - -# Claude Code local scheduled-task lock -.claude/scheduled_tasks.lock 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 fa12be2ab..0c2ecec68 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,3 @@ { - ".": "0.20.0", - "adk": "0.20.0" -} + ".": "0.20.0" +} \ No newline at end of file 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 3fd212e16..000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,1249 +0,0 @@ -# Changelog - -## Unreleased - -### โš  BREAKING CHANGES - -* **harness:** removed the deprecated bespoke LangGraph tracing handler `create_langgraph_tracing_handler` (and its `AgentexLangGraphTracingHandler` class) from the public `agentex.lib.adk` surface. Span tracing is now derived from the canonical `StreamTaskMessage*` stream by `UnifiedEmitter` โ€” wrap your run in the harness `*Turn` and drive `UnifiedEmitter.yield_turn` / `auto_send_turn`. The `agentex init` templates were migrated accordingly. -* **harness:** removed the deprecated bespoke Pydantic-AI tracing handler `create_pydantic_ai_tracing_handler` (and its `AgentexPydanticAITracingHandler` class) from the public `agentex.lib.adk` surface. Span tracing is now derived from the canonical `StreamTaskMessage*` stream by `UnifiedEmitter` โ€” wrap your run in `PydanticAITurn` and drive `UnifiedEmitter.yield_turn` / `auto_send_turn`. The `agentex init` templates were migrated accordingly. -* **harness:** each harness now exposes exactly `__sync.py` + `__turn.py` under `agentex.lib.adk._modules`. The OpenAI harness `OpenAITurn` and `convert_openai_to_agentex_events` moved to `agentex.lib.adk._modules._openai_turn` / `_openai_sync`; back-compat shims remain at `agentex.lib.adk.providers._modules.{openai_turn,sync_provider}` for one release. Public facade names (`stream_pydantic_ai_events`, `stream_langgraph_events`, `emit_langgraph_messages`, etc.) are unchanged. - -### Features - -* **tracing:** emit OTel metrics for async span queue depth, batch drain, and SGP export success/failure (HTTP status labels). Disable SDK-side recording with ``AGENTEX_TRACING_METRICS=0``. - -## 0.20.0 (2026-07-16) - -Full Changelog: [agentex-client-v0.19.0...agentex-client-v0.20.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.19.0...agentex-client-v0.20.0) - -### Features - -* **api:** add interrupt task ([936a2b1](https://github.com/scaleapi/scale-agentex-python/commit/936a2b1d28a5409bd7548d59b0f18491495c805b)) -* **api:** add task/interrupt method and INTERRUPTED status to agents/tasks ([4f1c093](https://github.com/scaleapi/scale-agentex-python/commit/4f1c09348c2ddffeb8c78738fb9b7aa14ed4b752)) -* **interrupt:** task/interrupt hook + protocol + resume-safe session capture (AGX1-391) ([#462](https://github.com/scaleapi/scale-agentex-python/issues/462)) ([eaa3dd5](https://github.com/scaleapi/scale-agentex-python/commit/eaa3dd526b88c454fc604d0e617f7191b66952bb)) - -## 0.19.0 (2026-07-14) - -Full Changelog: [agentex-client-v0.18.0...agentex-client-v0.19.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.18.0...agentex-client-v0.19.0) - -### Features - -* **tracing:** emit token usage on spans for SGP billing ([#458](https://github.com/scaleapi/scale-agentex-python/issues/458)) ([7d19ada](https://github.com/scaleapi/scale-agentex-python/commit/7d19ada2db5d1eca5268a10fe04dfc85a367cf7f)) - - -### Bug Fixes - -* **internal:** resolve build failures ([9245b70](https://github.com/scaleapi/scale-agentex-python/commit/9245b700ceee95be9a0c478e518d9c06228d4b9f)) -* **tracing:** capture span body exceptions and export SGP status=ERROR ([#460](https://github.com/scaleapi/scale-agentex-python/issues/460)) ([6c23d76](https://github.com/scaleapi/scale-agentex-python/commit/6c23d7625ccf58ac9793dcf5219e4f7f4de38353)) - -## 0.18.0 (2026-07-10) - -Full Changelog: [agentex-client-v0.17.0...agentex-client-v0.18.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.17.0...agentex-client-v0.18.0) - -### Features - -* **api:** add schedule resume ([56f41aa](https://github.com/scaleapi/scale-agentex-python/commit/56f41aa78a79ba7ff75acb909371354f6732a299)) -* **api:** add skipped_action_times field to agents schedule responses ([de49d43](https://github.com/scaleapi/scale-agentex-python/commit/de49d43d6dd4fd12896c519ebd745c87f224f596)) -* **api:** add webhook endpoint ([f1c1252](https://github.com/scaleapi/scale-agentex-python/commit/f1c1252edea74f7cb84deb11d5e915a1e5506ea6)) -* **api:** manual updates ([e855070](https://github.com/scaleapi/scale-agentex-python/commit/e855070cc8dcb4f5ffae96f55ba8862ac890dafc)) -* **api:** manual updates ([e3c8baf](https://github.com/scaleapi/scale-agentex-python/commit/e3c8baf19509319e9d5b545d95574cf92f24e63c)) -* **api:** remove retrieve/delete/pause/trigger/unpause, update create/list in schedules ([8f084b6](https://github.com/scaleapi/scale-agentex-python/commit/8f084b6080cb2492ea8d18f4209547be0c057437)) -* **api:** update schedule configs ([c1e7db8](https://github.com/scaleapi/scale-agentex-python/commit/c1e7db875930c532e61a8ab72ec3b62473caae3a)) -* Use stable handles for run schedules ([9145865](https://github.com/scaleapi/scale-agentex-python/commit/91458652755536383693466c1b63a357bf610099)) - - -### Bug Fixes - -* cap openai <2.45 for openai-agents 0.14.x compatibility ([#459](https://github.com/scaleapi/scale-agentex-python/issues/459)) ([14c124d](https://github.com/scaleapi/scale-agentex-python/commit/14c124d363ed964ed8c08e10a95ca3939095ea92)) - - -### Chores - -* **internal:** version bump ([7aeb893](https://github.com/scaleapi/scale-agentex-python/commit/7aeb8937bb794586f7d5931bdc5964d007762b4c)) -* **internal:** version bump ([fcddeea](https://github.com/scaleapi/scale-agentex-python/commit/fcddeea8ef4bdff0a5f7735156c3003166464eac)) -* **internal:** version bump ([0793543](https://github.com/scaleapi/scale-agentex-python/commit/079354303393c28c5087ce3907d4b5b4a64ee1c0)) - -## 0.17.0 (2026-07-01) - -Full Changelog: [agentex-client-v0.16.2...agentex-client-v0.17.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.16.2...agentex-client-v0.17.0) - -### Features - -* **temporal:** opt-in continue-as-new for long-lived agent workflows ([#447](https://github.com/scaleapi/scale-agentex-python/issues/447)) ([98cf744](https://github.com/scaleapi/scale-agentex-python/commit/98cf7444002b5f9862f3a922665f016ae6c89af0)) - -## 0.16.2 (2026-06-29) - -Full Changelog: [agentex-client-v0.16.1...agentex-client-v0.16.2](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.16.1...agentex-client-v0.16.2) - -### Bug Fixes - -* **adk:** release streaming buffer repair in sdk ([#449](https://github.com/scaleapi/scale-agentex-python/issues/449)) ([20795cb](https://github.com/scaleapi/scale-agentex-python/commit/20795cb158244767207b6d3758929014bc015bb6)) - -## 0.16.1 (2026-06-26) - -Full Changelog: [agentex-client-v0.16.0...agentex-client-v0.16.1](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.16.0...agentex-client-v0.16.1) - -### Bug Fixes - -* **streaming:** StreamTaskMessageFull closes the coalescing buffer ([#426](https://github.com/scaleapi/scale-agentex-python/issues/426)) ([94ce668](https://github.com/scaleapi/scale-agentex-python/commit/94ce6687a86ecac8ee1a6ee1b3448f463e3b0e83)) - -## 0.16.0 (2026-06-24) - -Full Changelog: [agentex-client-v0.15.0...agentex-client-v0.16.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.15.0...agentex-client-v0.16.0) - -### โš  BREAKING CHANGES - -* **harness:** consolidate the Pydantic-AI harness + remove tracing handler ([#431](https://github.com/scaleapi/scale-agentex-python/issues/431)) -* **harness:** consolidate the LangGraph harness + remove tracing handler ([#430](https://github.com/scaleapi/scale-agentex-python/issues/430)) - -### Features - -* **cli:** add claude-code init templates (sync / async / temporal) ([#435](https://github.com/scaleapi/scale-agentex-python/issues/435)) ([fd9bc4a](https://github.com/scaleapi/scale-agentex-python/commit/fd9bc4a81417b9d75ad692b779293720f8435d37)) -* **cli:** add codex init templates (sync / async / temporal) ([#436](https://github.com/scaleapi/scale-agentex-python/issues/436)) ([0fadfd7](https://github.com/scaleapi/scale-agentex-python/commit/0fadfd7a113536d49a99894a3b80ed0915a0e0fb)) -* **cli:** add default-openai-agents init template (async base) ([#434](https://github.com/scaleapi/scale-agentex-python/issues/434)) ([624e9c8](https://github.com/scaleapi/scale-agentex-python/commit/624e9c8f3b4c4288a7037bc83651970cfb02e6b0)) -* **openai-agents:** single-emit + input-bearing tool spans + run_turn ([#445](https://github.com/scaleapi/scale-agentex-python/issues/445)) ([53ab8ef](https://github.com/scaleapi/scale-agentex-python/commit/53ab8efaaf65590e71abe07149582ea59814921b)) -* **openai-temporal:** render hosted/server-side tool calls in TemporalStreamingModel ([#442](https://github.com/scaleapi/scale-agentex-python/issues/442)) ([5dce9f0](https://github.com/scaleapi/scale-agentex-python/commit/5dce9f097723d3436a0e40277139e7cce68580ef)) - - -### Bug Fixes - -* **cli:** harden init templates per Greptile feedback (suite-wide) ([#444](https://github.com/scaleapi/scale-agentex-python/issues/444)) ([2d85eb0](https://github.com/scaleapi/scale-agentex-python/commit/2d85eb0952f2298e6c412ab44b9c59255431cb84)) -* **harness:** harden Claude Code + OpenAI taps and span tracing ([#446](https://github.com/scaleapi/scale-agentex-python/issues/446)) ([5b4359d](https://github.com/scaleapi/scale-agentex-python/commit/5b4359dcf28f390f780215ed954fa52e8cb4dd7c)) - - -### Refactors - -* **harness:** consolidate the LangGraph harness + remove tracing handler ([#430](https://github.com/scaleapi/scale-agentex-python/issues/430)) ([a3fb5ad](https://github.com/scaleapi/scale-agentex-python/commit/a3fb5ad51f6392a48cbb8324f15c9619f10244b6)) -* **harness:** consolidate the Pydantic-AI harness + remove tracing handler ([#431](https://github.com/scaleapi/scale-agentex-python/issues/431)) ([48c3da8](https://github.com/scaleapi/scale-agentex-python/commit/48c3da8777ae20a9ca6d544238dccd64d6c62c2b)) -* **harness:** move OpenAI harness into adk/_modules + facade export ([#432](https://github.com/scaleapi/scale-agentex-python/issues/432)) ([58bdb16](https://github.com/scaleapi/scale-agentex-python/commit/58bdb16b4b18db22188a29d5d1b31759f9d0dd4e)) - -## 0.15.0 (2026-06-23) - -Full Changelog: [agentex-client-v0.14.0...agentex-client-v0.15.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.14.0...agentex-client-v0.15.0) - -### Features - -* **api:** add webhook endpoint ([37c7d9d](https://github.com/scaleapi/scale-agentex-python/commit/37c7d9d465943184ab84922ba1079b939516d534)) -* **claude-code:** stream-json parser tap for the unified harness surface ([#420](https://github.com/scaleapi/scale-agentex-python/issues/420)) ([904339c](https://github.com/scaleapi/scale-agentex-python/commit/904339c21b8cd641a02d903c03d4a8730b4d7e84)) -* **codex:** event-stream parser tap for the unified harness surface ([#421](https://github.com/scaleapi/scale-agentex-python/issues/421)) ([9b2b031](https://github.com/scaleapi/scale-agentex-python/commit/9b2b03144cc67bb497e0a301686207aba2629758)) -* **harness:** public adk facade + docs for the unified harness surface (PR 9) ([#423](https://github.com/scaleapi/scale-agentex-python/issues/423)) ([fa60632](https://github.com/scaleapi/scale-agentex-python/commit/fa60632f9be84315a3fdc627745ae5b605994bd8)) -* **harness:** unified harness surface โ€” foundation (span derivation, delivery adapters, emitter) ([#412](https://github.com/scaleapi/scale-agentex-python/issues/412)) ([a9cacf4](https://github.com/scaleapi/scale-agentex-python/commit/a9cacf4eb71697351ee658a570636f04bbf31ad5)) -* **langgraph:** migrate LangGraph harness onto unified surface ([#417](https://github.com/scaleapi/scale-agentex-python/issues/417)) ([d344228](https://github.com/scaleapi/scale-agentex-python/commit/d34422845de4b80ed69d2dccfdb0c680ef2fbca3)) -* **openai-agents:** migrate onto the unified harness surface ([#416](https://github.com/scaleapi/scale-agentex-python/issues/416)) ([d10e151](https://github.com/scaleapi/scale-agentex-python/commit/d10e1510bd5da44ad5acc5cac638750122083fce)) -* **pydantic-ai:** migrate onto unified harness surface (PR4) ([#415](https://github.com/scaleapi/scale-agentex-python/issues/415)) ([5ec62c2](https://github.com/scaleapi/scale-agentex-python/commit/5ec62c20781d24fc3e0b92734fcd444b1e791d70)) -* **sdk:** add webhook helper for forward-route handlers ([#419](https://github.com/scaleapi/scale-agentex-python/issues/419)) ([514075d](https://github.com/scaleapi/scale-agentex-python/commit/514075de2189f33be4ade0ac84368019e55ed7ea)) -* **streaming:** stream tool call argument deltas in TemporalStreamingModel ([#355](https://github.com/scaleapi/scale-agentex-python/issues/355)) ([c8de1d4](https://github.com/scaleapi/scale-agentex-python/commit/c8de1d4c9c3b5b3c16ad4aaf9644c1ba0d618757)) -* **tracing:** skip Agentex span-start write by default (end-only ingest) ([#438](https://github.com/scaleapi/scale-agentex-python/issues/438)) ([10d22a2](https://github.com/scaleapi/scale-agentex-python/commit/10d22a27091c9c410ae808dab9cfce5dab3816a8)) - - -### Bug Fixes - -* **harness:** assert cross-channel (yield vs auto-send) conformance equivalence [AGX1-373] ([#414](https://github.com/scaleapi/scale-agentex-python/issues/414)) ([694960f](https://github.com/scaleapi/scale-agentex-python/commit/694960f913b8ba521d9236e876e5e00f57a3a3ff)) -* **harness:** correct codex & openai reasoning stream envelopes ([#441](https://github.com/scaleapi/scale-agentex-python/issues/441)) ([1d86e8a](https://github.com/scaleapi/scale-agentex-python/commit/1d86e8a47a369814540b6e853cd20240c6098f27)) -* **tests:** use relative import for assert_matches_type in webhooks test ([#440](https://github.com/scaleapi/scale-agentex-python/issues/440)) ([5954a9f](https://github.com/scaleapi/scale-agentex-python/commit/5954a9fc8c7961ef5ceb41abf3ca32e6e78590c5)) -* **tracing:** fail open temporal span activities ([#437](https://github.com/scaleapi/scale-agentex-python/issues/437)) ([2d63eef](https://github.com/scaleapi/scale-agentex-python/commit/2d63eef53bdb919bb6568e04708e3b7abcb8075b)) - - -### Refactors - -* **cli:** migrate existing langgraph/pydantic-ai templates to unified surface ([#429](https://github.com/scaleapi/scale-agentex-python/issues/429)) ([ee41408](https://github.com/scaleapi/scale-agentex-python/commit/ee41408c420eba5c6b8fe8719c8ebd445dcd220c)) -* **tutorials:** migrate to the unified harness surface + renumber ([#428](https://github.com/scaleapi/scale-agentex-python/issues/428)) ([ebaf617](https://github.com/scaleapi/scale-agentex-python/commit/ebaf617256c7971dde12fd7e25f02b05f2f42fca)) - -## 0.14.0 (2026-06-22) - -Full Changelog: [agentex-client-v0.13.1...agentex-client-v0.14.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.13.1...agentex-client-v0.14.0) - -### Features - -* **api:** add is error to tools ([8ddd960](https://github.com/scaleapi/scale-agentex-python/commit/8ddd9604290d23ed59586a68bd6db46bf452104b)) -* **compat:** runtime SDKโ†”backend version guard at ACP startup ([#408](https://github.com/scaleapi/scale-agentex-python/issues/408)) ([433c999](https://github.com/scaleapi/scale-agentex-python/commit/433c999bbdb4817d2048c5454cb65b54812950af)) - - -### Bug Fixes - -* **types:** add missing Optional import to ToolResponseContent ([3439f6e](https://github.com/scaleapi/scale-agentex-python/commit/3439f6edec9ab89d685b5b1c99e567a67c911522)) - -## 0.13.1 (2026-06-17) - -Full Changelog: [agentex-client-v0.13.0...agentex-client-v0.13.1](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.13.0...agentex-client-v0.13.1) - -### Bug Fixes - -* **adk:** re-send task_id/agent_id in state updates for backend compatibility ([#405](https://github.com/scaleapi/scale-agentex-python/issues/405)) ([f59f26d](https://github.com/scaleapi/scale-agentex-python/commit/f59f26d4402f01318cf34d57820e121d97719986)) -* **packaging:** guard agentex-client surface, bump floor, smoke-test wheel install ([#406](https://github.com/scaleapi/scale-agentex-python/issues/406)) ([a5abbb9](https://github.com/scaleapi/scale-agentex-python/commit/a5abbb9669c6ab71c52e60db72676c95c20d840d)) - - -### Documentation - -* drop stale keep_files / dashboard-config comments ([#401](https://github.com/scaleapi/scale-agentex-python/issues/401)) ([23858df](https://github.com/scaleapi/scale-agentex-python/commit/23858df775d0a617c6418eed28f1b68c9bf9ed5c)) - -## 0.13.0 (2026-06-10) - -Full Changelog: [agentex-client-v0.12.0...agentex-client-v0.13.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.12.0...agentex-client-v0.13.0) - -### โš  BREAKING CHANGES - -* **packaging:** release tag scheme changes from v* to -v*. - -### Features - -* add AgentCard for self-describing agent capabilities ([#296](https://github.com/scaleapi/scale-agentex-python/issues/296)) ([6509be1](https://github.com/scaleapi/scale-agentex-python/commit/6509be1e5d9bc53e6058b22c45c760e04a4c4006)) -* add HTTP-proxy LangGraph checkpointer ([19fae2f](https://github.com/scaleapi/scale-agentex-python/commit/19fae2f6e3ce4302066a403cac4c6499410ec4ad)) -* add OCI Helm registry support for agent deployments ([#255](https://github.com/scaleapi/scale-agentex-python/issues/255)) ([5f054b5](https://github.com/scaleapi/scale-agentex-python/commit/5f054b514ff919479b0914883ed163279820c848)) -* **adk:** allow all ClaudeAgentOptions in run_claude_agent_activity ([25bbe24](https://github.com/scaleapi/scale-agentex-python/commit/25bbe24b57feaab2e557ca15279369bfb59e02db)) -* **adk:** Revamp run_claude_agent_activity to use more streaming ([#309](https://github.com/scaleapi/scale-agentex-python/issues/309)) ([0c16595](https://github.com/scaleapi/scale-agentex-python/commit/0c16595017164649bbea1bab8767010c9be7228d)) -* **api:** api update ([7b1b642](https://github.com/scaleapi/scale-agentex-python/commit/7b1b642404f34ff74d866e91a5ed2d6f0a4424c6)) -* **api:** api update ([710c63f](https://github.com/scaleapi/scale-agentex-python/commit/710c63f3a9b0494635c41e0d3498d69dc9145b81)) -* **api:** api update ([8abce2b](https://github.com/scaleapi/scale-agentex-python/commit/8abce2ba6131732688f04bacff33da506e47c77f)) -* **api:** Switch target to -client ([e741990](https://github.com/scaleapi/scale-agentex-python/commit/e74199029367ec7c626f5ea3057eb462e9f81b30)) -* **lib:** Add task updates to adk ([a58747f](https://github.com/scaleapi/scale-agentex-python/commit/a58747f0d85733f32f67b06eee222a1464eb87fe)) -* **openai_agents:** expose real `usage`, `response_id`, plumb `previous_response_id`, opt-in `prompt_cache_key` for stateful responses and prompt caching ([#335](https://github.com/scaleapi/scale-agentex-python/issues/335)) ([ba5d64b](https://github.com/scaleapi/scale-agentex-python/commit/ba5d64be1f959ff1a35b30e647a0a5ead21a8402)) -* **packaging:** introduce slim agentex-client + heavy agentex-sdk split ([bbfb22e](https://github.com/scaleapi/scale-agentex-python/commit/bbfb22eb113dd1f3d5ddf82b4d377895f5ae5466)) -* pass AGENTEX_DEPLOYMENT_ID in registration metadata ([#305](https://github.com/scaleapi/scale-agentex-python/issues/305)) ([31af8c6](https://github.com/scaleapi/scale-agentex-python/commit/31af8c6fc4aaafad57b70ded4883ced1254aeb1b)) -* **tracing:** Add background queue for async span processing ([#303](https://github.com/scaleapi/scale-agentex-python/issues/303)) ([3a60add](https://github.com/scaleapi/scale-agentex-python/commit/3a60add048ff24266a45700b4e78def8ffed3e0b)) - - -### Bug Fixes - -* add litellm retry with exponential backoff for rate limit errors ([ccdb24a](https://github.com/scaleapi/scale-agentex-python/commit/ccdb24a08607298f8dafd748ee9e7fe8ba13d5fe)) -* **adk:** fix to queue drain ([#327](https://github.com/scaleapi/scale-agentex-python/issues/327)) ([a862a06](https://github.com/scaleapi/scale-agentex-python/commit/a862a0646365d86acd4b0e1cf470fce522a6fbb3)) -* **api:** remove agent_id and task_id parameters from states update method ([a7cbaae](https://github.com/scaleapi/scale-agentex-python/commit/a7cbaae4416e2d712623ecfac5e251c07c537958)) -* **client:** preserve hardcoded query params when merging with user params ([d2c4788](https://github.com/scaleapi/scale-agentex-python/commit/d2c47883c4247a0c5a318042ff38384ddc8db4ea)) -* ensure file data are only sent as 1 parameter ([48fae27](https://github.com/scaleapi/scale-agentex-python/commit/48fae27b6a761984f7fb70cb7a87da76a4192d12)) -* render .env.example template in agentex init ([#351](https://github.com/scaleapi/scale-agentex-python/issues/351)) ([6092595](https://github.com/scaleapi/scale-agentex-python/commit/6092595fa8a267b2c305baba09e2682c04d593b3)) -* Temporal Union deserialization causing tool_response messages to be lost ([79ef4dd](https://github.com/scaleapi/scale-agentex-python/commit/79ef4dd7a0ab1b8bb1151f5e16124ec5a947dfd4)) -* **temporal:** allowing-ACP-temporal-telemetry ([9b44eb0](https://github.com/scaleapi/scale-agentex-python/commit/9b44eb0f5c6482984f972674d7a8612980c5b576)) -* **tests:** repair test_streaming_model so all 28 tests run and pass ([#334](https://github.com/scaleapi/scale-agentex-python/issues/334)) ([7e5e69c](https://github.com/scaleapi/scale-agentex-python/commit/7e5e69c132c89d054516e1a762e0437375859663)) -* **tracing:** Fix memory leak in SGP tracing processors ([#302](https://github.com/scaleapi/scale-agentex-python/issues/302)) ([f43dac4](https://github.com/scaleapi/scale-agentex-python/commit/f43dac4fa7ca7090b37c6c3bf285eb12515764bb)) -* **tutorials:** stop at130-langgraph workflow deadlock on graph compile ([#399](https://github.com/scaleapi/scale-agentex-python/issues/399)) ([bd90a61](https://github.com/scaleapi/scale-agentex-python/commit/bd90a613958a330f1a6670f621000a9aaed1025b)) - - -### Performance Improvements - -* **client:** optimize file structure copying in multipart requests ([f5064f9](https://github.com/scaleapi/scale-agentex-python/commit/f5064f939788d72fedac91436982a8848d0f1f4f)) -* **tracing:** larger span batch + linger_ms for high-volume ingest ([#397](https://github.com/scaleapi/scale-agentex-python/issues/397)) ([c0d6330](https://github.com/scaleapi/scale-agentex-python/commit/c0d633052d373daa63e8cefb9339736c0a7855fb)) -* **tracing:** skip span-start upsert by default (end-only ingest) ([#394](https://github.com/scaleapi/scale-agentex-python/issues/394)) ([ae1c7ca](https://github.com/scaleapi/scale-agentex-python/commit/ae1c7caa8599f5f82492086d04caae9a6d2b7c7d)) - - -### Chores - -* **ci:** upgrade `actions/github-script` ([7c867e8](https://github.com/scaleapi/scale-agentex-python/commit/7c867e8960b51234e5e41a9b8e3129c1dada5680)) -* gitignore .claude/scheduled_tasks.lock ([#400](https://github.com/scaleapi/scale-agentex-python/issues/400)) ([e186352](https://github.com/scaleapi/scale-agentex-python/commit/e1863526408451d087568676feafca033a4656c4)) - - -### Documentation - -* **api:** clarify name parameter behavior in agent task creation ([ce5af72](https://github.com/scaleapi/scale-agentex-python/commit/ce5af729cc3a0f05905d0cebfe2ef18c16d8563e)) -* clarify task name is optional in adk.acp.create_task ([#392](https://github.com/scaleapi/scale-agentex-python/issues/392)) ([bd41d9b](https://github.com/scaleapi/scale-agentex-python/commit/bd41d9bb10f08a354f02f982e6507847c19d2ad9)) - - -### Refactors - -* **config:** promote deployment-config models to agentex.config.* ([#396](https://github.com/scaleapi/scale-agentex-python/issues/396)) ([9825dba](https://github.com/scaleapi/scale-agentex-python/commit/9825dba3301754e2a86632214adcc62ff97e28bd)) - -## 0.12.0 (2026-06-02) - -Full Changelog: [v0.11.9...v0.12.0](https://github.com/scaleapi/scale-agentex-python/compare/v0.11.9...v0.12.0) - -### Features - -* **api:** Bump edition to switch rye -> UV ([1bd4ff7](https://github.com/scaleapi/scale-agentex-python/commit/1bd4ff7c3299ea4238cd3e36141f7e4b035967ef)) - - -### Bug Fixes - -* cap Python test matrix at 3.13 and align dev tooling versions ([#391](https://github.com/scaleapi/scale-agentex-python/issues/391)) ([729763c](https://github.com/scaleapi/scale-agentex-python/commit/729763c9652faf3a68386083d6f617dd48f642b7)) - -## 0.11.9 (2026-06-02) - -Full Changelog: [v0.11.8...v0.11.9](https://github.com/scaleapi/scale-agentex-python/compare/v0.11.8...v0.11.9) - -### Features - -* **api:** add register build api endpoint ([30c5da4](https://github.com/scaleapi/scale-agentex-python/commit/30c5da47d84ce2bfbfbb798c2f62b9552881db7d)) - -## 0.11.8 (2026-06-01) - -Full Changelog: [v0.11.7...v0.11.8](https://github.com/scaleapi/scale-agentex-python/compare/v0.11.7...v0.11.8) - -### Features - -* **cli:** add Temporal + LangGraph agent template and example ([#383](https://github.com/scaleapi/scale-agentex-python/issues/383)) ([bbc9e02](https://github.com/scaleapi/scale-agentex-python/commit/bbc9e02d2a2b063a3e509a07ffca8ca4bf459e57)) -* **tracing:** OTel span queue and export telemetry (SGPINF-1863) ([#373](https://github.com/scaleapi/scale-agentex-python/issues/373)) ([6669012](https://github.com/scaleapi/scale-agentex-python/commit/6669012638481a63bdd7629582818796ca31bdf3)) - -## 0.11.7 (2026-06-01) - -Full Changelog: [v0.11.6...v0.11.7](https://github.com/scaleapi/scale-agentex-python/compare/v0.11.6...v0.11.7) - -### Features - -* **examples:** OpenAI Agents SDK local-sandbox tutorials (sync + async + temporal) ([#377](https://github.com/scaleapi/scale-agentex-python/issues/377)) ([a66d239](https://github.com/scaleapi/scale-agentex-python/commit/a66d23955fa1a98296ef4e8b09c11afe9461268a)) - - -### Performance Improvements - -* **tracing:** bounded-concurrency span export ([#374](https://github.com/scaleapi/scale-agentex-python/issues/374)) ([7b32a0d](https://github.com/scaleapi/scale-agentex-python/commit/7b32a0d826b3ed864a3bf9de256ff8da1dafb942)) - - -### Chores - -* back-merge release 0.11.6 into next ([#384](https://github.com/scaleapi/scale-agentex-python/issues/384)) ([13d3eab](https://github.com/scaleapi/scale-agentex-python/commit/13d3eab0657f1dd5a8b7ade6c7381d3230d60aff)) - -## 0.11.6 (2026-05-29) - -Full Changelog: [v0.11.5...v0.11.6](https://github.com/scaleapi/scale-agentex-python/compare/v0.11.5...v0.11.6) - -### Features - -* **api:** add cleaned_at field to task response types ([38ed338](https://github.com/scaleapi/scale-agentex-python/commit/38ed3384094f7f07f6b2482489f457fd1dc4f76d)) -* **deps:** bump openai-agents to >=0.14.3 for scale-sandbox oai_agents adapter ([#375](https://github.com/scaleapi/scale-agentex-python/issues/375)) ([e1b31d9](https://github.com/scaleapi/scale-agentex-python/commit/e1b31d91abadec572989b805592b788500d61994)) -* **lib:** expose data_converter kwarg on AgentexWorker and Temporal client APIs ([#372](https://github.com/scaleapi/scale-agentex-python/issues/372)) ([d04624e](https://github.com/scaleapi/scale-agentex-python/commit/d04624e6899e43a0429ef2deeb84509265b9f636)) - - -### Bug Fixes - -* **tutorials:** restore tutorial CI deps after agentex-sdk 0.11.5 (pytest + debugpy) ([#379](https://github.com/scaleapi/scale-agentex-python/issues/379)) ([0a2418c](https://github.com/scaleapi/scale-agentex-python/commit/0a2418cc9f9b06e3bdc46099106e50d226412fa0)) - - -### Performance Improvements - -* **tracing:** span queue linger + per-loop httpx keepalive ([#362](https://github.com/scaleapi/scale-agentex-python/issues/362)) ([feec842](https://github.com/scaleapi/scale-agentex-python/commit/feec8426f79e9f02533451d44997717655fd33f2)) - - -### Chores - -* back-merge release 0.11.5 into next ([#381](https://github.com/scaleapi/scale-agentex-python/issues/381)) ([ab5a7d9](https://github.com/scaleapi/scale-agentex-python/commit/ab5a7d9732a56d47efad469675c7630046106ef6)) -* **deps:** drop unused runtime deps and exclude tests from wheel ([#367](https://github.com/scaleapi/scale-agentex-python/issues/367)) ([f4303d1](https://github.com/scaleapi/scale-agentex-python/commit/f4303d1e7211783d19beca6554e44eb73bb29c42)) - - -### Refactors - -* **types:** promote protocol types to agentex.protocol.* ([#371](https://github.com/scaleapi/scale-agentex-python/issues/371)) ([6f1c14f](https://github.com/scaleapi/scale-agentex-python/commit/6f1c14fd61077da52038361642a9fbc4a0a56c8b)) - -## 0.11.5 (2026-05-29) - -Full Changelog: [v0.11.4...v0.11.5](https://github.com/scaleapi/scale-agentex-python/compare/v0.11.4...v0.11.5) - -### Features - -* **api:** add cleaned_at field to task response types ([38ed338](https://github.com/scaleapi/scale-agentex-python/commit/38ed3384094f7f07f6b2482489f457fd1dc4f76d)) -* **deps:** bump openai-agents to >=0.14.3 for scale-sandbox oai_agents adapter ([#375](https://github.com/scaleapi/scale-agentex-python/issues/375)) ([e1b31d9](https://github.com/scaleapi/scale-agentex-python/commit/e1b31d91abadec572989b805592b788500d61994)) - - -### Performance Improvements - -* **tracing:** span queue linger + per-loop httpx keepalive ([#362](https://github.com/scaleapi/scale-agentex-python/issues/362)) ([feec842](https://github.com/scaleapi/scale-agentex-python/commit/feec8426f79e9f02533451d44997717655fd33f2)) - - -### Chores - -* **deps:** drop unused runtime deps and exclude tests from wheel ([#367](https://github.com/scaleapi/scale-agentex-python/issues/367)) ([f4303d1](https://github.com/scaleapi/scale-agentex-python/commit/f4303d1e7211783d19beca6554e44eb73bb29c42)) - - -### Refactors - -* **types:** promote protocol types to agentex.protocol.* ([#371](https://github.com/scaleapi/scale-agentex-python/issues/371)) ([6f1c14f](https://github.com/scaleapi/scale-agentex-python/commit/6f1c14fd61077da52038361642a9fbc4a0a56c8b)) - -## 0.11.4 (2026-05-26) - -Full Changelog: [v0.11.3...v0.11.4](https://github.com/scaleapi/scale-agentex-python/compare/v0.11.3...v0.11.4) - -### Chores - -* **deps:** relax redis pin to support 6.x/7.x ([#363](https://github.com/scaleapi/scale-agentex-python/issues/363)) ([7817ced](https://github.com/scaleapi/scale-agentex-python/commit/7817ced90b80430a69b6f51a6841aa921a33a093)) -* relax requires-python floor to >= 3.11 ([#366](https://github.com/scaleapi/scale-agentex-python/issues/366)) ([a064f92](https://github.com/scaleapi/scale-agentex-python/commit/a064f928c0fac868ec1486ef49382a9baf73b5e0)) - -## 0.11.3 (2026-05-20) - -Full Changelog: [v0.11.2...v0.11.3](https://github.com/scaleapi/scale-agentex-python/compare/v0.11.2...v0.11.3) - -### Features - -* added Pydantic AI sync, async, temporal integration ([#359](https://github.com/scaleapi/scale-agentex-python/issues/359)) ([781dfe1](https://github.com/scaleapi/scale-agentex-python/commit/781dfe172373c2e01fb642b3c98af6908c98218a)) -* **api:** add schedule, checkpoints, and deployment endpoints ([53b5c36](https://github.com/scaleapi/scale-agentex-python/commit/53b5c3673e54ee4b49debd049483f1a1d4b0673d)) - - -### Bug Fixes - -* resolve lint and test failures from new endpoints ([#360](https://github.com/scaleapi/scale-agentex-python/issues/360)) ([bdf129c](https://github.com/scaleapi/scale-agentex-python/commit/bdf129c8ab976ed84aa9932d5585a753280a6a34)) - -## 0.11.2 (2026-05-13) - -Full Changelog: [v0.11.1...v0.11.2](https://github.com/scaleapi/scale-agentex-python/compare/v0.11.1...v0.11.2) - -### Bug Fixes - -* **messages:** stamp agent messages with workflow.now() for monotonic ordering ([#356](https://github.com/scaleapi/scale-agentex-python/issues/356)) ([afe5265](https://github.com/scaleapi/scale-agentex-python/commit/afe526509393d7f51e4edc261211792992ffee58)) - -## 0.11.1 (2026-05-13) - -Full Changelog: [v0.11.0...v0.11.1](https://github.com/scaleapi/scale-agentex-python/compare/v0.11.0...v0.11.1) - -### โš  BREAKING CHANGES - -* remove AgentexTracingProcessor from default tracing processors ([#349](https://github.com/scaleapi/scale-agentex-python/issues/349)) - -### Features - -* **api:** add models for event requests, surface created_at for messages ([1998d73](https://github.com/scaleapi/scale-agentex-python/commit/1998d73741ed32f6e527d847a7c951a6f880cab9)) -* **api:** api update ([da06505](https://github.com/scaleapi/scale-agentex-python/commit/da065051e22cd49f7d47facd33db5bbb50d61f6d)) -* **api:** revert model additions ([a02c15b](https://github.com/scaleapi/scale-agentex-python/commit/a02c15bfe1169a84d59647d409755d7bfcc029d0)) -* **internal/types:** support eagerly validating pydantic iterators ([2c528c6](https://github.com/scaleapi/scale-agentex-python/commit/2c528c6db24cb64b7fffadafe3e8c46f316f0d56)) -* remove AgentexTracingProcessor from default tracing processors ([#349](https://github.com/scaleapi/scale-agentex-python/issues/349)) ([73eca7a](https://github.com/scaleapi/scale-agentex-python/commit/73eca7ad620a7e0a8bd0180b9dee02a7dde12dbb)) -* **streaming:** emit OTel metrics for ttft, tps, token counts ([#347](https://github.com/scaleapi/scale-agentex-python/issues/347)) ([3bf7d1f](https://github.com/scaleapi/scale-agentex-python/commit/3bf7d1f32f95e1346cdc823e3d1f4f027635e2dd)) - - -### Bug Fixes - -* **client:** add missing f-string prefix in file type error message ([dcb1cb4](https://github.com/scaleapi/scale-agentex-python/commit/dcb1cb489bc565828c16c327c5ab6b678b13c0fa)) -* render .env.example template in agentex init ([#351](https://github.com/scaleapi/scale-agentex-python/issues/351)) ([6092595](https://github.com/scaleapi/scale-agentex-python/commit/6092595fa8a267b2c305baba09e2682c04d593b3)) -* **tracing:** make SGP processor stateless to stop dropping span closes ([#354](https://github.com/scaleapi/scale-agentex-python/issues/354)) ([5e9f28d](https://github.com/scaleapi/scale-agentex-python/commit/5e9f28d2f1453b3b6faf993acf9f67a6fd098952)) -* wire SGP_CLIENT_BASE_URL and silence openai-agents tracer in templates ([#352](https://github.com/scaleapi/scale-agentex-python/issues/352)) ([870324e](https://github.com/scaleapi/scale-agentex-python/commit/870324e7bb87cefc20a79dc344d8603a836ca9b5)) - -## 0.11.0 (2026-05-07) - -Full Changelog: [v0.10.5...v0.11.0](https://github.com/scaleapi/scale-agentex-python/compare/v0.10.5...v0.11.0) - -### Features - -* make workflow execution timeout configurable via env var ([#348](https://github.com/scaleapi/scale-agentex-python/issues/348)) ([4094708](https://github.com/scaleapi/scale-agentex-python/commit/4094708a84026aafe19eae19d022118bb26e1a72)) - -## 0.10.5 (2026-05-05) - -Full Changelog: [v0.10.4...v0.10.5](https://github.com/scaleapi/scale-agentex-python/compare/v0.10.4...v0.10.5) - -### Features - -* **api:** api update ([ffaecd5](https://github.com/scaleapi/scale-agentex-python/commit/ffaecd5a94b4082f9ef38d5c89286eabf3811759)) -* **openai_agents:** expose real `usage`, `response_id`, plumb `previous_response_id`, opt-in `prompt_cache_key` for stateful responses and prompt caching ([#335](https://github.com/scaleapi/scale-agentex-python/issues/335)) ([ba5d64b](https://github.com/scaleapi/scale-agentex-python/commit/ba5d64be1f959ff1a35b30e647a0a5ead21a8402)) - - -### Chores - -* **internal:** reformat pyproject.toml ([ba06702](https://github.com/scaleapi/scale-agentex-python/commit/ba06702fd362656d594f73852ad2c690383892a8)) -* **internal:** reformat pyproject.toml ([3faf5d5](https://github.com/scaleapi/scale-agentex-python/commit/3faf5d5927abdc3036862d4d06e085cda0eb6cd4)) -* **internal:** version bump ([168cc44](https://github.com/scaleapi/scale-agentex-python/commit/168cc44f8199015e232cd2bddf1669a08ee90778)) -* **internal:** version bump ([5715828](https://github.com/scaleapi/scale-agentex-python/commit/5715828a358c20b1cc895a696d0c8d803ec71932)) - -## 0.10.4 (2026-05-04) - -Full Changelog: [v0.10.3...v0.10.4](https://github.com/scaleapi/scale-agentex-python/compare/v0.10.3...v0.10.4) - -### Features - -* add service account id option for registering agentex agents ([8365771](https://github.com/scaleapi/scale-agentex-python/commit/83657710ddb95d61bb5173ca881fe602344ff495)) - -## 0.10.3 (2026-04-30) - -Full Changelog: [v0.10.2...v0.10.3](https://github.com/scaleapi/scale-agentex-python/compare/v0.10.2...v0.10.3) - -### Features - -* **api:** api update ([16ab771](https://github.com/scaleapi/scale-agentex-python/commit/16ab771ab1396b94c768ec5185c2f8ed07eff556)) -* **api:** api update ([fe77732](https://github.com/scaleapi/scale-agentex-python/commit/fe77732da48c872739bc6296d2932d4d9c810a35)) -* support setting headers via env ([a73fd73](https://github.com/scaleapi/scale-agentex-python/commit/a73fd73ea036fc195c124636337acdc0552f18f1)) - - -### Bug Fixes - -* **adk:** Always inject headers on execute activity ([#337](https://github.com/scaleapi/scale-agentex-python/issues/337)) ([9d80e0b](https://github.com/scaleapi/scale-agentex-python/commit/9d80e0b797a9ed7a0838003294dc7a595ab18de5)) -* allow litellm security patch ([#336](https://github.com/scaleapi/scale-agentex-python/issues/336)) ([c980948](https://github.com/scaleapi/scale-agentex-python/commit/c9809482d5e6095063115d1851f0b92a5e5a3755)) -* **tests:** repair test_streaming_model so all 28 tests run and pass ([#334](https://github.com/scaleapi/scale-agentex-python/issues/334)) ([7e5e69c](https://github.com/scaleapi/scale-agentex-python/commit/7e5e69c132c89d054516e1a762e0437375859663)) -* use correct field name format for multipart file arrays ([bd6d362](https://github.com/scaleapi/scale-agentex-python/commit/bd6d362aee81873b7969b0367488029e2bb0314b)) - - -### Performance Improvements - -* **streaming:** coalesce per-token publishes to Redis (50ms / 128-char window) ([#333](https://github.com/scaleapi/scale-agentex-python/issues/333)) ([e6f11c4](https://github.com/scaleapi/scale-agentex-python/commit/e6f11c45e6dc3186770088688ad45cc251387e4a)) - - -### Chores - -* **internal:** more robust bootstrap script ([f004301](https://github.com/scaleapi/scale-agentex-python/commit/f0043013a44ddcd9f356a8e0a548e4a295cb1b1d)) - -## 0.10.2 (2026-04-21) - -Full Changelog: [v0.10.1...v0.10.2](https://github.com/scaleapi/scale-agentex-python/compare/v0.10.1...v0.10.2) - -### Features - -* **api:** api update ([d5b9945](https://github.com/scaleapi/scale-agentex-python/commit/d5b99455c248a629bb2c56a2b5daf192d9f70db8)) - - -### Bug Fixes - -* **adk:** fix to queue drain ([#327](https://github.com/scaleapi/scale-agentex-python/issues/327)) ([b59d6d8](https://github.com/scaleapi/scale-agentex-python/commit/b59d6d8b59cec9548ec468cae3827d785c9f86f7)) - - -### Performance Improvements - -* **client:** optimize file structure copying in multipart requests ([87fe899](https://github.com/scaleapi/scale-agentex-python/commit/87fe899713a2ec88f1c32b347a7d5c78124aaf56)) - -## 0.10.1 (2026-04-17) - -Full Changelog: [v0.10.0...v0.10.1](https://github.com/scaleapi/scale-agentex-python/compare/v0.10.0...v0.10.1) - -## 0.10.0 (2026-04-14) - -Full Changelog: [v0.9.10...v0.10.0](https://github.com/scaleapi/scale-agentex-python/compare/v0.9.10...v0.10.0) - -### Features - -* add AgentCard for self-describing agent capabilities ([#296](https://github.com/scaleapi/scale-agentex-python/issues/296)) ([6509be1](https://github.com/scaleapi/scale-agentex-python/commit/6509be1e5d9bc53e6058b22c45c760e04a4c4006)) -* **api:** api update ([8abce2b](https://github.com/scaleapi/scale-agentex-python/commit/8abce2ba6131732688f04bacff33da506e47c77f)) - - -### Bug Fixes - -* ensure file data are only sent as 1 parameter ([48fae27](https://github.com/scaleapi/scale-agentex-python/commit/48fae27b6a761984f7fb70cb7a87da76a4192d12)) -* Temporal Union deserialization causing tool_response messages to be lost ([79ef4dd](https://github.com/scaleapi/scale-agentex-python/commit/79ef4dd7a0ab1b8bb1151f5e16124ec5a947dfd4)) -* **temporal:** allowing-ACP-temporal-telemetry ([9b44eb0](https://github.com/scaleapi/scale-agentex-python/commit/9b44eb0f5c6482984f972674d7a8612980c5b576)) - -## 0.9.10 (2026-04-07) - -Full Changelog: [v0.9.9...v0.9.10](https://github.com/scaleapi/scale-agentex-python/compare/v0.9.9...v0.9.10) - -### Features - -* **adk:** Revamp run_claude_agent_activity to use more streaming ([#309](https://github.com/scaleapi/scale-agentex-python/issues/309)) ([25069d3](https://github.com/scaleapi/scale-agentex-python/commit/25069d3dccc7534ecfba114b581878af758c3487)) - -## 0.9.9 (2026-04-07) - -Full Changelog: [v0.9.8...v0.9.9](https://github.com/scaleapi/scale-agentex-python/compare/v0.9.8...v0.9.9) - -### Bug Fixes - -* **client:** preserve hardcoded query params when merging with user params ([4a97659](https://github.com/scaleapi/scale-agentex-python/commit/4a97659b768335bc241e78d3897a9bd665ce1a25)) - -## 0.9.8 (2026-04-06) - -Full Changelog: [v0.9.7...v0.9.8](https://github.com/scaleapi/scale-agentex-python/compare/v0.9.7...v0.9.8) - -### Features - -* **adk:** allow all ClaudeAgentOptions in run_claude_agent_activity ([e41aec7](https://github.com/scaleapi/scale-agentex-python/commit/e41aec738f230070c5db1dcbf7e08abc1ef538d9)) -* pass AGENTEX_DEPLOYMENT_ID in registration metadata ([#305](https://github.com/scaleapi/scale-agentex-python/issues/305)) ([31af8c6](https://github.com/scaleapi/scale-agentex-python/commit/31af8c6fc4aaafad57b70ded4883ced1254aeb1b)) -* **tracing:** Add background queue for async span processing ([#303](https://github.com/scaleapi/scale-agentex-python/issues/303)) ([3a60add](https://github.com/scaleapi/scale-agentex-python/commit/3a60add048ff24266a45700b4e78def8ffed3e0b)) - - -### Bug Fixes - -* **tracing:** Fix memory leak in SGP tracing processors ([#302](https://github.com/scaleapi/scale-agentex-python/issues/302)) ([f43dac4](https://github.com/scaleapi/scale-agentex-python/commit/f43dac4fa7ca7090b37c6c3bf285eb12515764bb)) - -## 0.9.7 (2026-03-30) - -Full Changelog: [v0.9.6...v0.9.7](https://github.com/scaleapi/scale-agentex-python/compare/v0.9.6...v0.9.7) - -### Features - -* **lib:** Add task updates to adk ([ff12ae1](https://github.com/scaleapi/scale-agentex-python/commit/ff12ae199b38223c7c71b703fc8b11d5de99b0d8)) - -## 0.9.6 (2026-03-30) - -Full Changelog: [v0.9.5...v0.9.6](https://github.com/scaleapi/scale-agentex-python/compare/v0.9.5...v0.9.6) - -### Features - -* **api:** add task state update methods ([d699e24](https://github.com/scaleapi/scale-agentex-python/commit/d699e245d6c8f28034370ea6a654e11a3b78dc20)) -* **api:** keep backwards compatible models ([3ec2a1e](https://github.com/scaleapi/scale-agentex-python/commit/3ec2a1e9987cd69fbcfeee8a8a6449b6825a1d49)) -* **api:** use DeploymentHistory instead of DeploymentHistoryRetrieveResponse ([4c63d9c](https://github.com/scaleapi/scale-agentex-python/commit/4c63d9c340e56d7f602f77f2f1fb33b005577402)) -* **internal:** implement indices array format for query and form serialization ([3bf3db1](https://github.com/scaleapi/scale-agentex-python/commit/3bf3db1f692b44ceb5f4ea39cb8c4fd0f81c01ee)) - - -### Bug Fixes - -* **deps:** bump minimum typing-extensions version ([fd76bc9](https://github.com/scaleapi/scale-agentex-python/commit/fd76bc994dca633c4966967c132323985eafa642)) -* **pydantic:** do not pass `by_alias` unless set ([235636b](https://github.com/scaleapi/scale-agentex-python/commit/235636b424dd4595f1510a87e6b79f3b2e103c97)) -* sanitize endpoint path params ([e6472be](https://github.com/scaleapi/scale-agentex-python/commit/e6472bea7d34a72d070079441b359bef25e87830)) - - -### Chores - -* **ci:** skip lint on metadata-only changes ([f4d5053](https://github.com/scaleapi/scale-agentex-python/commit/f4d5053766e5864338229218f2402d60f431d1fa)) -* **ci:** skip uploading artifacts on stainless-internal branches ([45e7622](https://github.com/scaleapi/scale-agentex-python/commit/45e76227d8b0d5d1c2f398e9945b71eb5953e791)) -* format all `api.md` files ([e67fa69](https://github.com/scaleapi/scale-agentex-python/commit/e67fa69c072f462ea86ecd67b888afa5f97cc7cc)) -* **internal:** add request options to SSE classes ([b788da0](https://github.com/scaleapi/scale-agentex-python/commit/b788da0d1b9fb6100dffb4a99b761ddcb7f0160e)) -* **internal:** bump dependencies ([95112dd](https://github.com/scaleapi/scale-agentex-python/commit/95112dd25a3bf8a49bd1080bfddefd403e64cfcb)) -* **internal:** fix lint error on Python 3.14 ([cb99db1](https://github.com/scaleapi/scale-agentex-python/commit/cb99db1857e373c3dc47d4f5ff6861d06b0ddce4)) -* **internal:** make `test_proxy_environment_variables` more resilient ([7bfaa75](https://github.com/scaleapi/scale-agentex-python/commit/7bfaa75be00bf8f11030f42a3dc6fdcd980c5823)) -* **internal:** make `test_proxy_environment_variables` more resilient to env ([fd1a06e](https://github.com/scaleapi/scale-agentex-python/commit/fd1a06e212cf1a314ac7c61e4d51879401e120f9)) -* **internal:** remove mock server code ([3a5ae0f](https://github.com/scaleapi/scale-agentex-python/commit/3a5ae0f0451610ae56284307d4c2bee1ac2964c1)) -* **internal:** tweak CI branches ([2e74af0](https://github.com/scaleapi/scale-agentex-python/commit/2e74af08e3e2dd4179550e9dd1cf22881195ac91)) -* **internal:** update gitignore ([aba7c4f](https://github.com/scaleapi/scale-agentex-python/commit/aba7c4f8264fdad515a4926884f855c2d87aa910)) -* **internal:** version bump ([1ef69ed](https://github.com/scaleapi/scale-agentex-python/commit/1ef69ed5415d3112055a8040eccfb6eca452e532)) -* **internal:** version bump ([1132255](https://github.com/scaleapi/scale-agentex-python/commit/1132255a0cd7aec1daed38e4110cd6bac53f930a)) -* **internal:** version bump ([60e5402](https://github.com/scaleapi/scale-agentex-python/commit/60e5402c4502957aee7848ab3cdcbfb41503a8ae)) -* update mock server docs ([8c5c6d3](https://github.com/scaleapi/scale-agentex-python/commit/8c5c6d38214b13f645f6fbd75efbbb8116458589)) - -## 0.9.5 (2026-03-24) - -Full Changelog: [v0.9.4...v0.9.5](https://github.com/scaleapi/scale-agentex-python/compare/v0.9.4...v0.9.5) - -## 0.9.4 (2026-02-18) - -Full Changelog: [v0.9.3...v0.9.4](https://github.com/scaleapi/scale-agentex-python/compare/v0.9.3...v0.9.4) - -## 0.9.3 (2026-02-13) - -Full Changelog: [v0.9.2...v0.9.3](https://github.com/scaleapi/scale-agentex-python/compare/v0.9.2...v0.9.3) - -### Features - -* add HTTP-proxy LangGraph checkpointer ([19fae2f](https://github.com/scaleapi/scale-agentex-python/commit/19fae2f6e3ce4302066a403cac4c6499410ec4ad)) -* add OCI Helm registry support for agent deployments ([#255](https://github.com/scaleapi/scale-agentex-python/issues/255)) ([5f054b5](https://github.com/scaleapi/scale-agentex-python/commit/5f054b514ff919479b0914883ed163279820c848)) - -## 0.9.2 (2026-02-06) - -Full Changelog: [v0.9.1...v0.9.2](https://github.com/scaleapi/scale-agentex-python/compare/v0.9.1...v0.9.2) - -### Features - -* **client:** add custom JSON encoder for extended type support ([a0720ab](https://github.com/scaleapi/scale-agentex-python/commit/a0720abb088583ce4b596e464f7483a4be728e29)) - - -### Bug Fixes - -* add litellm retry with exponential backoff for rate limit errors ([ccdb24a](https://github.com/scaleapi/scale-agentex-python/commit/ccdb24a08607298f8dafd748ee9e7fe8ba13d5fe)) - -## 0.9.1 (2026-01-26) - -Full Changelog: [v0.9.0...v0.9.1](https://github.com/scaleapi/scale-agentex-python/compare/v0.9.0...v0.9.1) - -### Chores - -* **ci:** upgrade `actions/github-script` ([71d5c6c](https://github.com/scaleapi/scale-agentex-python/commit/71d5c6c67362f18e0cbdc27cca37672778ff6b1f)) - -## 0.9.0 (2026-01-21) - -Full Changelog: [v0.8.2...v0.9.0](https://github.com/scaleapi/scale-agentex-python/compare/v0.8.2...v0.9.0) - -### Features - -* **api:** api update ([33ade28](https://github.com/scaleapi/scale-agentex-python/commit/33ade2859c35413ecb4972a68a85cc0ef426e864)) -* **client:** add support for binary request streaming ([07e2881](https://github.com/scaleapi/scale-agentex-python/commit/07e2881a23ad2c624306c8d10ab661ddef42deec)) - - -### Chores - -* **internal:** update `actions/checkout` version ([64d91f6](https://github.com/scaleapi/scale-agentex-python/commit/64d91f6984c577e0a8a1546bc0f96f944d343a7d)) - -## 0.8.2 (2026-01-02) - -Full Changelog: [v0.8.1...v0.8.2](https://github.com/scaleapi/scale-agentex-python/compare/v0.8.1...v0.8.2) - -### Features - -* **api:** api update ([f2115eb](https://github.com/scaleapi/scale-agentex-python/commit/f2115ebf273043a87ea50b39837138bfc30a63d6)) - -## 0.8.1 (2025-12-22) - -Full Changelog: [v0.8.0...v0.8.1](https://github.com/scaleapi/scale-agentex-python/compare/v0.8.0...v0.8.1) - -### Features - -* **api:** add messages/paginated endpoint ([3e03aff](https://github.com/scaleapi/scale-agentex-python/commit/3e03aff8490e0556cb05052d385156eda8f28107)) -* **api:** add messages/paginated to stainless config ([2473ded](https://github.com/scaleapi/scale-agentex-python/commit/2473ded39274bcd0a16d7314667fcf7f55e829c2)) -* **api:** api update ([2e4ec2f](https://github.com/scaleapi/scale-agentex-python/commit/2e4ec2f28413ee58afa664b793565d6be4da5dfe)) -* **api:** api update ([ed21ad8](https://github.com/scaleapi/scale-agentex-python/commit/ed21ad8c34cd11e80af9128181764489a0541740)) -* **api:** api update ([86a166a](https://github.com/scaleapi/scale-agentex-python/commit/86a166aba5538411ebcc0ed74291505e01a466f2)) -* **api:** api update ([4c95c94](https://github.com/scaleapi/scale-agentex-python/commit/4c95c94df570277fc49281f1343cb012e8da2334)) -* **api:** api update ([f6eccdf](https://github.com/scaleapi/scale-agentex-python/commit/f6eccdf975eaef9b257ef3f20f087f2f2f9b3665)) -* **api:** api update ([41067fb](https://github.com/scaleapi/scale-agentex-python/commit/41067fb79725787e0ceb20dcf16029998bcbca24)) -* **api:** api update ([cdc9c63](https://github.com/scaleapi/scale-agentex-python/commit/cdc9c636be6f26e84772d1d1ef9d47cddcd9dabc)) -* **api:** api update ([413d9c8](https://github.com/scaleapi/scale-agentex-python/commit/413d9c806d918d7c5da3d0249c0f11d4b9f0894e)) -* **api:** api update ([1b4bf7d](https://github.com/scaleapi/scale-agentex-python/commit/1b4bf7d3a11306a50ec0eb9c20764c585d0e98e4)) -* **api:** manual updates ([131e836](https://github.com/scaleapi/scale-agentex-python/commit/131e836b5bda8248f847b00308b6711a1ee84ee0)) -* **api:** update via SDK Studio ([2a6c7fa](https://github.com/scaleapi/scale-agentex-python/commit/2a6c7fa919ad255f9e53e7f97f195065599a05e9)) - - -### Bug Fixes - -* ensure streams are always closed ([7bb9db8](https://github.com/scaleapi/scale-agentex-python/commit/7bb9db851a213d261e585cd2f156046f05cf85db)) -* **types:** allow pyright to infer TypedDict types within SequenceNotStr ([9cfc9d6](https://github.com/scaleapi/scale-agentex-python/commit/9cfc9d66579a11f3eaf248bafbfddb422e878a58)) -* use async_to_httpx_files in patch method ([8abb539](https://github.com/scaleapi/scale-agentex-python/commit/8abb539a340af3a2a42482757412c0c408817461)) - - -### Chores - -* add missing docstrings ([81f1fa9](https://github.com/scaleapi/scale-agentex-python/commit/81f1fa9b3c440d893b8ea8f773ab2592eb333d65)) -* **deps:** mypy 1.18.1 has a regression, pin to 1.17 ([e20aaa4](https://github.com/scaleapi/scale-agentex-python/commit/e20aaa495384f547dd18c8d31496f70b4a37e0dd)) -* **docs:** use environment variables for authentication in code snippets ([a30f6ae](https://github.com/scaleapi/scale-agentex-python/commit/a30f6aebca8de5be72eb7bcf7a3b3ccea28479bc)) -* **internal:** add `--fix` argument to lint script ([0ef4242](https://github.com/scaleapi/scale-agentex-python/commit/0ef4242888cc6ed341536e1ab1fbf6b03c723de9)) -* **internal:** add missing files argument to base client ([28d1738](https://github.com/scaleapi/scale-agentex-python/commit/28d1738d3af8feb00f6f641e159221fb41c42983)) -* speedup initial import ([8e50946](https://github.com/scaleapi/scale-agentex-python/commit/8e50946321c32e42a7b25cf9ae8b8e9b020a7ac9)) -* update lockfile ([a3a2e4f](https://github.com/scaleapi/scale-agentex-python/commit/a3a2e4fbcf6e6e4bcbadab50c6b9236e4514dae2)) - -## 0.8.0 (2025-12-17) - -Full Changelog: [v0.7.4...v0.8.0](https://github.com/scaleapi/scale-agentex-python/compare/v0.7.4...v0.8.0) - -### Features - -* **api:** api update ([2e4ec2f](https://github.com/scaleapi/scale-agentex-python/commit/2e4ec2f28413ee58afa664b793565d6be4da5dfe)) - - -### Bug Fixes - -* use async_to_httpx_files in patch method ([8abb539](https://github.com/scaleapi/scale-agentex-python/commit/8abb539a340af3a2a42482757412c0c408817461)) - -## 0.7.4 (2025-12-17) - -Full Changelog: [v0.7.3...v0.7.4](https://github.com/scaleapi/scale-agentex-python/compare/v0.7.3...v0.7.4) - -### Features - -* **api:** api update ([ed21ad8](https://github.com/scaleapi/scale-agentex-python/commit/ed21ad8c34cd11e80af9128181764489a0541740)) -* **api:** api update ([86a166a](https://github.com/scaleapi/scale-agentex-python/commit/86a166aba5538411ebcc0ed74291505e01a466f2)) -* **api:** api update ([4c95c94](https://github.com/scaleapi/scale-agentex-python/commit/4c95c94df570277fc49281f1343cb012e8da2334)) - - -### Chores - -* **internal:** add missing files argument to base client ([28d1738](https://github.com/scaleapi/scale-agentex-python/commit/28d1738d3af8feb00f6f641e159221fb41c42983)) -* speedup initial import ([8e50946](https://github.com/scaleapi/scale-agentex-python/commit/8e50946321c32e42a7b25cf9ae8b8e9b020a7ac9)) - -## 0.7.3 (2025-12-10) - -Full Changelog: [v0.7.2...v0.7.3](https://github.com/scaleapi/scale-agentex-python/compare/v0.7.2...v0.7.3) - -## 0.7.2 (2025-12-10) - -Full Changelog: [v0.7.1...v0.7.2](https://github.com/scaleapi/scale-agentex-python/compare/v0.7.1...v0.7.2) - -## 0.7.1 (2025-12-09) - -Full Changelog: [v0.7.0...v0.7.1](https://github.com/scaleapi/scale-agentex-python/compare/v0.7.0...v0.7.1) - -### Features - -* **api:** api update ([92b2710](https://github.com/scaleapi/scale-agentex-python/commit/92b2710e0f060a8d59f8d8237c3ca7b8e923867a)) - -## 0.7.0 (2025-12-09) - -Full Changelog: [v0.6.7...v0.7.0](https://github.com/scaleapi/scale-agentex-python/compare/v0.6.7...v0.7.0) - -### Features - -* **api:** add messages/paginated endpoint ([3e03aff](https://github.com/scaleapi/scale-agentex-python/commit/3e03aff8490e0556cb05052d385156eda8f28107)) -* **api:** add messages/paginated to stainless config ([2473ded](https://github.com/scaleapi/scale-agentex-python/commit/2473ded39274bcd0a16d7314667fcf7f55e829c2)) -* **api:** api update ([f6eccdf](https://github.com/scaleapi/scale-agentex-python/commit/f6eccdf975eaef9b257ef3f20f087f2f2f9b3665)) -* **api:** api update ([41067fb](https://github.com/scaleapi/scale-agentex-python/commit/41067fb79725787e0ceb20dcf16029998bcbca24)) -* **api:** api update ([cdc9c63](https://github.com/scaleapi/scale-agentex-python/commit/cdc9c636be6f26e84772d1d1ef9d47cddcd9dabc)) -* **api:** api update ([413d9c8](https://github.com/scaleapi/scale-agentex-python/commit/413d9c806d918d7c5da3d0249c0f11d4b9f0894e)) -* **api:** api update ([1b4bf7d](https://github.com/scaleapi/scale-agentex-python/commit/1b4bf7d3a11306a50ec0eb9c20764c585d0e98e4)) -* **api:** manual updates ([131e836](https://github.com/scaleapi/scale-agentex-python/commit/131e836b5bda8248f847b00308b6711a1ee84ee0)) - - -### Bug Fixes - -* ensure streams are always closed ([7bb9db8](https://github.com/scaleapi/scale-agentex-python/commit/7bb9db851a213d261e585cd2f156046f05cf85db)) -* **types:** allow pyright to infer TypedDict types within SequenceNotStr ([9cfc9d6](https://github.com/scaleapi/scale-agentex-python/commit/9cfc9d66579a11f3eaf248bafbfddb422e878a58)) - - -### Chores - -* add missing docstrings ([81f1fa9](https://github.com/scaleapi/scale-agentex-python/commit/81f1fa9b3c440d893b8ea8f773ab2592eb333d65)) -* **deps:** mypy 1.18.1 has a regression, pin to 1.17 ([e20aaa4](https://github.com/scaleapi/scale-agentex-python/commit/e20aaa495384f547dd18c8d31496f70b4a37e0dd)) -* **docs:** use environment variables for authentication in code snippets ([a30f6ae](https://github.com/scaleapi/scale-agentex-python/commit/a30f6aebca8de5be72eb7bcf7a3b3ccea28479bc)) -* update lockfile ([a3a2e4f](https://github.com/scaleapi/scale-agentex-python/commit/a3a2e4fbcf6e6e4bcbadab50c6b9236e4514dae2)) - -## 0.6.7 (2025-11-19) - -Full Changelog: [v0.6.6...v0.6.7](https://github.com/scaleapi/scale-agentex-python/compare/v0.6.6...v0.6.7) - -## 0.6.6 (2025-11-12) - -Full Changelog: [v0.6.5...v0.6.6](https://github.com/scaleapi/scale-agentex-python/compare/v0.6.5...v0.6.6) - -### Bug Fixes - -* compat with Python 3.14 ([9a62f23](https://github.com/scaleapi/scale-agentex-python/commit/9a62f23376ef797bafe67f61552eb7635286caa3)) -* **compat:** update signatures of `model_dump` and `model_dump_json` for Pydantic v1 ([cf857f9](https://github.com/scaleapi/scale-agentex-python/commit/cf857f9191f10a971e9cba2a8c764229ed4a7dfe)) - - -### Chores - -* **internal:** restore stats ([5ec0383](https://github.com/scaleapi/scale-agentex-python/commit/5ec0383d9d6a85b342263ba49b8e3893924c59fc)) -* **package:** drop Python 3.8 support ([3d4dc37](https://github.com/scaleapi/scale-agentex-python/commit/3d4dc37f87b8d8f1debbe6505746342e461772ba)) - -## 0.6.5 (2025-11-06) - -Full Changelog: [v0.6.4...v0.6.5](https://github.com/scaleapi/scale-agentex-python/compare/v0.6.4...v0.6.5) - -## 0.6.4 (2025-11-06) - -Full Changelog: [v0.6.3...v0.6.4](https://github.com/scaleapi/scale-agentex-python/compare/v0.6.3...v0.6.4) - -## 0.6.3 (2025-11-06) - -Full Changelog: [v0.6.2...v0.6.3](https://github.com/scaleapi/scale-agentex-python/compare/v0.6.2...v0.6.3) - -## 0.6.2 (2025-11-05) - -Full Changelog: [v0.6.1...v0.6.2](https://github.com/scaleapi/scale-agentex-python/compare/v0.6.1...v0.6.2) - -### Features - -* **api:** update via SDK Studio ([b732dfa](https://github.com/scaleapi/scale-agentex-python/commit/b732dfac50cacc90c84a751fd6c75d18fa5b43ed)) - -## 0.6.1 (2025-11-05) - -Full Changelog: [v0.6.0...v0.6.1](https://github.com/scaleapi/scale-agentex-python/compare/v0.6.0...v0.6.1) - -### Features - -* **api:** api update ([f6189a4](https://github.com/scaleapi/scale-agentex-python/commit/f6189a43e1430fdd16c8d10e6ad835d9dfa5871c)) -* **api:** api update ([714c719](https://github.com/scaleapi/scale-agentex-python/commit/714c7194e488e6070c99e200b91189f50dcdb831)) - -## 0.6.0 (2025-11-04) - -Full Changelog: [v0.5.3...v0.6.0](https://github.com/scaleapi/scale-agentex-python/compare/v0.5.3...v0.6.0) - -### Features - -* **api:** api update ([ec61dd3](https://github.com/scaleapi/scale-agentex-python/commit/ec61dd3124fbf169dcdcced262a30bfbed080b5f)) - - -### Chores - -* **internal:** grammar fix (it's -> its) ([36e27da](https://github.com/scaleapi/scale-agentex-python/commit/36e27daed52435b300f090ac4643cd502a817a1e)) - -## 0.5.3 (2025-10-31) - -Full Changelog: [v0.5.2...v0.5.3](https://github.com/scaleapi/scale-agentex-python/compare/v0.5.2...v0.5.3) - -### Chores - -* re apply example updates ([043973b](https://github.com/scaleapi/scale-agentex-python/commit/043973bec649ab2304eff7a313938e1e3e5377e5)) - -## 0.5.2 (2025-10-31) - -Full Changelog: [v0.5.0...v0.5.2](https://github.com/scaleapi/scale-agentex-python/compare/v0.5.0...v0.5.2) - -### Features - -* **api:** manual updates ([dc66b57](https://github.com/scaleapi/scale-agentex-python/commit/dc66b57618525669b3aa15676343ef542675a5f9)) -* bump the helm chart version ([1ffafb0](https://github.com/scaleapi/scale-agentex-python/commit/1ffafb0406138d6abd84254fa394b88c4a28ce70)) - - -### Chores - -* sync repo ([0e05416](https://github.com/scaleapi/scale-agentex-python/commit/0e05416219ca93ae347e6175804bc0f2259a6b44)) - -## 0.5.0 (2025-10-28) - -Full Changelog: [v0.4.28...v0.5.0](https://github.com/scaleapi/agentex-python/compare/v0.4.28...v0.5.0) - -### Features - -* **api:** api update ([129fae6](https://github.com/scaleapi/agentex-python/commit/129fae69844e655b5dd02b6f67c44d15f5dbfa93)) - -## 0.4.28 (2025-10-28) - -Full Changelog: [v0.4.27...v0.4.28](https://github.com/scaleapi/agentex-python/compare/v0.4.27...v0.4.28) - -## 0.4.27 (2025-10-27) - -Full Changelog: [v0.4.26...v0.4.27](https://github.com/scaleapi/agentex-python/compare/v0.4.26...v0.4.27) - -### Features - -* **api:** api update ([f5e4fd2](https://github.com/scaleapi/agentex-python/commit/f5e4fd2f2fbb2c7e67e51795fba1f0b2e13048de)) - -## 0.4.26 (2025-10-21) - -Full Changelog: [v0.4.25...v0.4.26](https://github.com/scaleapi/agentex-python/compare/v0.4.25...v0.4.26) - -### Features - -* **api:** api update ([0c1dedd](https://github.com/scaleapi/agentex-python/commit/0c1dedd0fecb05e3684f110cc589f2abe55acb97)) -* **api:** api update ([719dc74](https://github.com/scaleapi/agentex-python/commit/719dc74f7844e2a3c14e46996e353d9c632b8e0a)) - - -### Chores - -* bump `httpx-aiohttp` version to 0.1.9 ([21c7921](https://github.com/scaleapi/agentex-python/commit/21c79210a0d65944fec5010fcc581a2d85fb94ab)) - -## 0.4.25 (2025-10-10) - -Full Changelog: [v0.4.24...v0.4.25](https://github.com/scaleapi/agentex-python/compare/v0.4.24...v0.4.25) - -## 0.4.24 (2025-10-10) - -Full Changelog: [v0.4.23...v0.4.24](https://github.com/scaleapi/agentex-python/compare/v0.4.23...v0.4.24) - -### Features - -* **api:** manual updates ([09996ea](https://github.com/scaleapi/agentex-python/commit/09996ea688a7225670bdd9d944b64801fac7acce)) - - -### Bug Fixes - -* health check port handling ([#138](https://github.com/scaleapi/agentex-python/issues/138)) ([fe22301](https://github.com/scaleapi/agentex-python/commit/fe223012db49768f38c4de56b5d5744031b631d1)) - - -### Chores - -* do not install brew dependencies in ./scripts/bootstrap by default ([2675e14](https://github.com/scaleapi/agentex-python/commit/2675e14bf9f3a0113a849caf2283376c448f9d03)) -* improve example values ([6997fe5](https://github.com/scaleapi/agentex-python/commit/6997fe57910ea54d6d71b25fdea4497925c8ec63)) -* **internal:** detect missing future annotations with ruff ([f1aa71f](https://github.com/scaleapi/agentex-python/commit/f1aa71f89bb0e8369e6d895b5111dc15fd1e2c12)) -* **internal:** update pydantic dependency ([156ea64](https://github.com/scaleapi/agentex-python/commit/156ea64a4fa317d3ab483e7b9b6ba63471b618ef)) -* **internal:** version bump ([8567752](https://github.com/scaleapi/agentex-python/commit/85677527f5c8d393f0eea0a2a629da48fb56f4a9)) -* **internal:** version bump ([45206dd](https://github.com/scaleapi/agentex-python/commit/45206dd28643403800c386b75e1c9a442c8978ae)) -* **internal:** version bump ([98354ba](https://github.com/scaleapi/agentex-python/commit/98354ba2e7630798e25a8e278cba44c1aacc1e08)) -* **internal:** version bump ([aa2a8db](https://github.com/scaleapi/agentex-python/commit/aa2a8db5907f78b4b39849a1900dae27412359bb)) -* **internal:** version bump ([73bba2a](https://github.com/scaleapi/agentex-python/commit/73bba2a59e77fa31caab5b668781b71bc7c5ec2d)) -* **types:** change optional parameter type from NotGiven to Omit ([2117d77](https://github.com/scaleapi/agentex-python/commit/2117d77219da097e784d5d2deab1632a2855dae9)) - -## 0.4.23 (2025-10-02) - -Full Changelog: [v0.4.22...v0.4.23](https://github.com/scaleapi/agentex-python/compare/v0.4.22...v0.4.23) - -### Features - -* Adding Agent info to SGP tracing metadata ([#85](https://github.com/scaleapi/agentex-python/issues/85)) ([900f66b](https://github.com/scaleapi/agentex-python/commit/900f66b60bc61ac515a7e43172d573a31c623fa9)) - -## 0.4.22 (2025-10-01) - -Full Changelog: [v0.4.21...v0.4.22](https://github.com/scaleapi/agentex-python/compare/v0.4.21...v0.4.22) - -## 0.4.21 (2025-10-01) - -Full Changelog: [v0.4.20...v0.4.21](https://github.com/scaleapi/agentex-python/compare/v0.4.20...v0.4.21) - -## 0.4.20 (2025-10-01) - -Full Changelog: [v0.4.19...v0.4.20](https://github.com/scaleapi/agentex-python/compare/v0.4.19...v0.4.20) - -## 0.4.19 (2025-10-01) - -Full Changelog: [v0.4.18...v0.4.19](https://github.com/scaleapi/agentex-python/compare/v0.4.18...v0.4.19) - -### Features - -* Adds helm config to Agent Environment ([#125](https://github.com/scaleapi/agentex-python/issues/125)) ([e4b39b5](https://github.com/scaleapi/agentex-python/commit/e4b39b5f319452bbc6650a7ef41b3a3179bb3b93)) - -## 0.4.18 (2025-09-29) - -Full Changelog: [v0.4.17...v0.4.18](https://github.com/scaleapi/agentex-python/compare/v0.4.17...v0.4.18) - -### Chores - -* **internal:** version bump ([eded756](https://github.com/scaleapi/agentex-python/commit/eded756bde2f3b4cfcf02c7a9cf72e70a82dd9aa)) - -## 0.4.17 (2025-09-29) - -Full Changelog: [v0.4.16...v0.4.17](https://github.com/scaleapi/agentex-python/compare/v0.4.16...v0.4.17) - -## 0.4.16 (2025-09-16) - -Full Changelog: [v0.4.15...v0.4.16](https://github.com/scaleapi/agentex-python/compare/v0.4.15...v0.4.16) - -## 0.4.15 (2025-09-16) - -Full Changelog: [v0.4.14...v0.4.15](https://github.com/scaleapi/agentex-python/compare/v0.4.14...v0.4.15) - -## 0.4.14 (2025-09-16) - -Full Changelog: [v0.4.13...v0.4.14](https://github.com/scaleapi/agentex-python/compare/v0.4.13...v0.4.14) - -### Features - -* add previous_response_id parameter to OpenAI module ([7a78844](https://github.com/scaleapi/agentex-python/commit/7a78844f9efbfac606c7e52d1f469db0728c9e56)) - -## 0.4.13 (2025-09-12) - -Full Changelog: [v0.4.12...v0.4.13](https://github.com/scaleapi/agentex-python/compare/v0.4.12...v0.4.13) - -### Features - -* **api:** api update ([0102183](https://github.com/scaleapi/agentex-python/commit/0102183a8f5a23dbdaf905ffbe7ffbcf59bf7b21)) -* **api:** api update ([8a6edb1](https://github.com/scaleapi/agentex-python/commit/8a6edb13046ca24bf6c45fc018e32de498d48869)) - -## 0.4.12 (2025-09-08) - -Full Changelog: [v0.4.11...v0.4.12](https://github.com/scaleapi/agentex-python/compare/v0.4.11...v0.4.12) - -### โš  BREAKING CHANGES - -* task_cancel now requires explicit agent_name/agent_id parameter to identify which agent owns the task being cancelled - -### Bug Fixes - -* task cancellation architectural bug ([f9a72a9](https://github.com/scaleapi/agentex-python/commit/f9a72a94f96afe86d3cc80f4f85ea368279d4517)) - -## 0.4.11 (2025-09-04) - -Full Changelog: [v0.4.10...v0.4.11](https://github.com/scaleapi/agentex-python/compare/v0.4.10...v0.4.11) - -### Features - -* Guardrail support ([e3e9bf9](https://github.com/scaleapi/agentex-python/commit/e3e9bf9dd6cf16b9a783638690d4a31914be8139)) -* improve future compat with pydantic v3 ([f0d8624](https://github.com/scaleapi/agentex-python/commit/f0d86244065c88bb2777db8fabeb1921e7e01116)) -* multiple guardrails ([ea8f98a](https://github.com/scaleapi/agentex-python/commit/ea8f98a973ba486e854cf14528a88eb73a203cf8)) -* **templates:** add custom activity timeout guidance for temporal agents ([7658256](https://github.com/scaleapi/agentex-python/commit/765825680132677ea0351f2a9410f472ee754906)) -* **types:** replace List[str] with SequenceNotStr in params ([f319781](https://github.com/scaleapi/agentex-python/commit/f3197818637574cd92b2c1f710679155eddf5af7)) - - -### Bug Fixes - -* Adding new example for guardrails instead of using 10_async ([15dc44b](https://github.com/scaleapi/agentex-python/commit/15dc44b333a977564c9974cc089d5ef578840714)) -* avoid newer type syntax ([6b5c82a](https://github.com/scaleapi/agentex-python/commit/6b5c82aab9ebcf755575b641aced2b77a13a71c3)) - - -### Chores - -* **internal:** add Sequence related utils ([496034d](https://github.com/scaleapi/agentex-python/commit/496034db4d6cba361c1f392a4bb86f6ab057e878)) -* **internal:** change ci workflow machines ([7445d94](https://github.com/scaleapi/agentex-python/commit/7445d94cb860f92911ec97ecd951149557956c6a)) -* **internal:** move mypy configurations to `pyproject.toml` file ([e96cd34](https://github.com/scaleapi/agentex-python/commit/e96cd34629d5ea173446c3184fbfe28bd2b370a0)) -* **internal:** update pyright exclude list ([d952430](https://github.com/scaleapi/agentex-python/commit/d952430ab4cbc41bca06010bbcfea3eeb022073e)) - -## 0.4.10 (2025-08-24) - -Full Changelog: [v0.4.9...v0.4.10](https://github.com/scaleapi/agentex-python/compare/v0.4.9...v0.4.10) - -## 0.4.9 (2025-08-22) - -Full Changelog: [v0.4.8...v0.4.9](https://github.com/scaleapi/agentex-python/compare/v0.4.8...v0.4.9) - -## 0.4.8 (2025-08-22) - -Full Changelog: [v0.4.7...v0.4.8](https://github.com/scaleapi/agentex-python/compare/v0.4.7...v0.4.8) - -## 0.4.7 (2025-08-22) - -Full Changelog: [v0.4.6...v0.4.7](https://github.com/scaleapi/agentex-python/compare/v0.4.6...v0.4.7) - -### Chores - -* update github action ([677e95d](https://github.com/scaleapi/agentex-python/commit/677e95de075b7031cfc4971d7d09769daaa5b2af)) - -## 0.4.6 (2025-08-20) - -Full Changelog: [v0.4.5...v0.4.6](https://github.com/scaleapi/agentex-python/compare/v0.4.5...v0.4.6) - -### Features - -* **api:** api update ([7b4c80a](https://github.com/scaleapi/agentex-python/commit/7b4c80acb502c29df63a3d66a1b29b653d2e3cf5)) - - -### Chores - -* generate release ([0836e4a](https://github.com/scaleapi/agentex-python/commit/0836e4a632e8f3aa0cd05fc6b61581f8c8be9bcd)) - -## 0.4.5 (2025-08-20) - -Full Changelog: [v0.4.4...v0.4.5](https://github.com/scaleapi/agentex-python/compare/v0.4.4...v0.4.5) - -### Features - -* **api:** manual updates ([34a53aa](https://github.com/scaleapi/agentex-python/commit/34a53aa28b8f862d74dd1603d92b7dd5dd28ddb1)) - - -### Bug Fixes - -* enable FunctionTool serialization for Temporal worker nodes ([c9eb040](https://github.com/scaleapi/agentex-python/commit/c9eb04002825195187cd58f34c9185349a63566e)) -* **tools:** handle callable objects in model serialization to facilitate tool calling ([4e9bb87](https://github.com/scaleapi/agentex-python/commit/4e9bb87d7faa2c2e1643893a168f7c6affd2809d)) - - -### Chores - -* demonstrate FunctionTool use in a (temporal) tutorial ([3a72043](https://github.com/scaleapi/agentex-python/commit/3a7204333c328fab8ba0f1d31fd26994ea176ecf)) - -## 0.4.4 (2025-08-17) - -Full Changelog: [v0.4.3...v0.4.4](https://github.com/scaleapi/agentex-python/compare/v0.4.3...v0.4.4) - -## 0.4.3 (2025-08-17) - -Full Changelog: [v0.4.2...v0.4.3](https://github.com/scaleapi/agentex-python/compare/v0.4.2...v0.4.3) - -## 0.4.2 (2025-08-17) - -Full Changelog: [v0.4.1...v0.4.2](https://github.com/scaleapi/agentex-python/compare/v0.4.1...v0.4.2) - -## 0.4.1 (2025-08-16) - -Full Changelog: [v0.4.0...v0.4.1](https://github.com/scaleapi/agentex-python/compare/v0.4.0...v0.4.1) - -## 0.4.0 (2025-08-15) - -Full Changelog: [v0.3.0...v0.4.0](https://github.com/scaleapi/agentex-python/compare/v0.3.0...v0.4.0) - -### Features - -* **api:** manual updates ([ce2a201](https://github.com/scaleapi/agentex-python/commit/ce2a201227ff6659874672fc7c6a890f25dfaa08)) -* **api:** manual updates ([7afbafd](https://github.com/scaleapi/agentex-python/commit/7afbafd03fdcbd464305fe6f0592141117d3527c)) - -## 0.3.0 (2025-08-14) - -Full Changelog: [v0.2.10...v0.3.0](https://github.com/scaleapi/agentex-python/compare/v0.2.10...v0.3.0) - -### Features - -* **api:** api update ([ad779b4](https://github.com/scaleapi/agentex-python/commit/ad779b4ce6a9f21b4f69c88770269b404ac25818)) -* **api:** manual updates ([9dc2f75](https://github.com/scaleapi/agentex-python/commit/9dc2f7511750884ec6754d91e6d27592f85b72e5)) - -## 0.2.10 (2025-08-13) - -Full Changelog: [v0.2.9...v0.2.10](https://github.com/scaleapi/agentex-python/compare/v0.2.9...v0.2.10) - -## 0.2.9 (2025-08-12) - -Full Changelog: [v0.2.8...v0.2.9](https://github.com/scaleapi/agentex-python/compare/v0.2.8...v0.2.9) - -### Chores - -* **internal:** update test skipping reason ([4affc92](https://github.com/scaleapi/agentex-python/commit/4affc925c69ed626d429732b470d4d1535b1be8d)) - -## 0.2.8 (2025-08-09) - -Full Changelog: [v0.2.7...v0.2.8](https://github.com/scaleapi/agentex-python/compare/v0.2.7...v0.2.8) - -### Chores - -* **internal:** update comment in script ([401f1d7](https://github.com/scaleapi/agentex-python/commit/401f1d79034ecb0b556a26debde79681bc21e8ae)) -* update @stainless-api/prism-cli to v5.15.0 ([4d332d0](https://github.com/scaleapi/agentex-python/commit/4d332d0f77a5a11ca6781a5fc7690ae82653cadb)) - -## 0.2.7 (2025-08-08) - -Full Changelog: [v0.2.6...v0.2.7](https://github.com/scaleapi/agentex-python/compare/v0.2.6...v0.2.7) - -### Features - -* **api:** api update ([e3d08ba](https://github.com/scaleapi/agentex-python/commit/e3d08baad59346db48e04a394a929d6347dafa07)) -* debug features ([40d8db2](https://github.com/scaleapi/agentex-python/commit/40d8db22dcc8f00a6c78e9bc3e1d036ebd1423b6)) - - -### Chores - -* **internal:** fix ruff target version ([1b880e1](https://github.com/scaleapi/agentex-python/commit/1b880e1dd81d47bb9df12507f13351611ff6367f)) - -## 0.2.6 (2025-08-01) - -Full Changelog: [v0.2.5...v0.2.6](https://github.com/scaleapi/agentex-python/compare/v0.2.5...v0.2.6) - -### Features - -* **api:** add query params to tasks.list ([d4902d5](https://github.com/scaleapi/agentex-python/commit/d4902d52caf82e2f57d1bbf19527cdc1448ed397)) -* **client:** support file upload requests ([e004b30](https://github.com/scaleapi/agentex-python/commit/e004b304c22286151330c2200bcb85046a7ac111)) - -## 0.2.5 (2025-07-30) - -Full Changelog: [v0.2.4...v0.2.5](https://github.com/scaleapi/agentex-python/compare/v0.2.4...v0.2.5) - -### Features - -* **api:** api update ([f90002c](https://github.com/scaleapi/agentex-python/commit/f90002c247a94cddc17307fb4eded12359cc9ad8)) -* **api:** api update ([aee4ad1](https://github.com/scaleapi/agentex-python/commit/aee4ad10e588386e9af1b4828d16ddba1805dca0)) -* **api:** manual updates ([55efcdd](https://github.com/scaleapi/agentex-python/commit/55efcdd55f2a20d1172da95cd551751d8be0d0df)) - -## 0.2.4 (2025-07-29) - -Full Changelog: [v0.2.3...v0.2.4](https://github.com/scaleapi/agentex-python/compare/v0.2.3...v0.2.4) - -## 0.2.3 (2025-07-29) - -Full Changelog: [v0.2.2...v0.2.3](https://github.com/scaleapi/agentex-python/compare/v0.2.2...v0.2.3) - -## 0.2.2 (2025-07-28) - -Full Changelog: [v0.2.1...v0.2.2](https://github.com/scaleapi/agentex-python/compare/v0.2.1...v0.2.2) - -### Features - -* **api:** api update ([eb79533](https://github.com/scaleapi/agentex-python/commit/eb79533dd041b7fccccc6a75abedd0c87e9c55e5)) - -## 0.2.1 (2025-07-27) - -Full Changelog: [v0.2.0...v0.2.1](https://github.com/scaleapi/agentex-python/compare/v0.2.0...v0.2.1) - -## 0.2.0 (2025-07-25) - -Full Changelog: [v0.1.1...v0.2.0](https://github.com/scaleapi/agentex-python/compare/v0.1.1...v0.2.0) - -### Features - -* **api:** update typescript sdk with big changes ([2c75d64](https://github.com/scaleapi/agentex-python/commit/2c75d642348df727505778c347efa568930ea4f0)) - - -### Chores - -* **project:** add settings file for vscode ([0f926cc](https://github.com/scaleapi/agentex-python/commit/0f926cce7df375de33627f8212caacf64f89b1ed)) - -## 0.1.1 (2025-07-24) - -Full Changelog: [v0.1.0...v0.1.1](https://github.com/scaleapi/agentex-python/compare/v0.1.0...v0.1.1) - -### Features - -* **api:** manual updates ([714e97e](https://github.com/scaleapi/agentex-python/commit/714e97ed1813a4a91b421fb77fadaf2afac2450d)) -* **api:** manual updates ([8dccfbd](https://github.com/scaleapi/agentex-python/commit/8dccfbdd9b8b887bfb99c79a9a28163215560ae4)) -* **api:** manual updates ([03af884](https://github.com/scaleapi/agentex-python/commit/03af884e31a3df4d42a863c06c5ab4dfc2374374)) - -## 0.1.0 (2025-07-23) - -Full Changelog: [v0.1.0-alpha.6...v0.1.0](https://github.com/scaleapi/agentex-python/compare/v0.1.0-alpha.6...v0.1.0) - -### Features - -* **api:** manual updates ([84010e4](https://github.com/scaleapi/agentex-python/commit/84010e4adecf7c779abd9a828000a3b50d9d3ac3)) - -## 0.1.0-alpha.6 (2025-07-23) - -Full Changelog: [v0.1.0-alpha.5...v0.1.0-alpha.6](https://github.com/scaleapi/agentex-python/compare/v0.1.0-alpha.5...v0.1.0-alpha.6) - -### Features - -* **api:** api update ([af18034](https://github.com/scaleapi/agentex-python/commit/af18034e4173794ebf42eff688f26d64caca4e64)) -* **api:** api update ([be9b603](https://github.com/scaleapi/agentex-python/commit/be9b60326817566d5c5edcbd7b7babb6db07e539)) -* **api:** manual updates ([bbe3be3](https://github.com/scaleapi/agentex-python/commit/bbe3be30aa9fb8d7a677f0e9f0be4dd565563d6e)) - -## 0.1.0-alpha.5 (2025-07-23) - -Full Changelog: [v0.1.0-alpha.4...v0.1.0-alpha.5](https://github.com/scaleapi/agentex-python/compare/v0.1.0-alpha.4...v0.1.0-alpha.5) - -### Features - -* **api:** deprecate name subresource ([14881c0](https://github.com/scaleapi/agentex-python/commit/14881c0ff2922e0a622975a0f5b314de99d7aabb)) -* **api:** manual updates ([d999a43](https://github.com/scaleapi/agentex-python/commit/d999a438c409f04b7e36b5df2d9b080d1d1b0e4a)) -* **api:** manual updates ([a885d8d](https://github.com/scaleapi/agentex-python/commit/a885d8dbabfe2cc2a556ef02e75e5502fd799c46)) - - -### Bug Fixes - -* **api:** build errors ([7bde6b7](https://github.com/scaleapi/agentex-python/commit/7bde6b727d6d16ebd6805ef843596fc3224445a6)) -* **parsing:** parse extra field types ([d40e6e0](https://github.com/scaleapi/agentex-python/commit/d40e6e0d6911be0bc9bfc419e02bd7c1d5ad5be4)) - -## 0.1.0-alpha.4 (2025-07-22) - -Full Changelog: [v0.1.0-alpha.3...v0.1.0-alpha.4](https://github.com/scaleapi/agentex-python/compare/v0.1.0-alpha.3...v0.1.0-alpha.4) - -## 0.1.0-alpha.3 (2025-07-22) - -Full Changelog: [v0.1.0-alpha.2...v0.1.0-alpha.3](https://github.com/scaleapi/agentex-python/compare/v0.1.0-alpha.2...v0.1.0-alpha.3) - -### Features - -* **api:** api update ([afedf45](https://github.com/scaleapi/agentex-python/commit/afedf4541ba6219cd04ef7af39a1d451abde75a4)) - -## 0.1.0-alpha.2 (2025-07-22) - -Full Changelog: [v0.1.0-alpha.1...v0.1.0-alpha.2](https://github.com/scaleapi/agentex-python/compare/v0.1.0-alpha.1...v0.1.0-alpha.2) - -## 0.1.0-alpha.1 (2025-07-22) - -Full Changelog: [v0.0.1-alpha.1...v0.1.0-alpha.1](https://github.com/scaleapi/agentex-python/compare/v0.0.1-alpha.1...v0.1.0-alpha.1) - -### Features - -* **api:** manual updates ([06f5fe1](https://github.com/scaleapi/agentex-python/commit/06f5fe115ace5ec4ca8149cd0afa6207b193a04c)) - -## 0.0.1-alpha.1 (2025-07-22) - -Full Changelog: [v0.0.1-alpha.0...v0.0.1-alpha.1](https://github.com/scaleapi/agentex-python/compare/v0.0.1-alpha.0...v0.0.1-alpha.1) - -### Chores - -* sync repo ([bc305f4](https://github.com/scaleapi/agentex-python/commit/bc305f43efedb5b7d7b28eaa059bce1d280c9dbb)) -* update SDK settings ([e5a06b4](https://github.com/scaleapi/agentex-python/commit/e5a06b4e3d8f8ad15d55b92393d7ddd833415f86)) diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 7dd7f2ed3..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,133 +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/config/` - **Canonical** location for deployment/agent - configuration models (manifest shapes). Depends only on `pydantic`, so it is - safe to import from a slim REST-only install. - - `agent_config.py`, `build_config.py`, `deployment_config.py`, - `local_development_config.py`, `environment_config.py`, `agent_manifest.py` - (model classes only), plus their model deps `credentials.py` and - `agent_configs.py` - - yaml loaders / build machinery (`load_environments_config*`, - `load_agent_manifest`, `build_context_manager`, `BuildContextManager`) stay - in `agentex.lib.sdk.config.*` so these models stay slim-safe -- `/src/agentex/lib/` - Custom library code (not modified by code generator) - - `/cli/` - Command-line interface implementation - - `/core/` - Core services, adapters, and temporal workflows - - `/sdk/` - SDK utilities and FastACP implementation - - `config/` - manifest loaders + Docker build machinery (`agent_manifest`'s - `load_agent_manifest`/`build_context_manager`/`BuildContextManager`, - `validation`, `project_config`) plus **back-compat shims** for the model - classes now canonical under `agentex.config.*` - - `/types/` - Custom type definitions - - `acp.py`, `json_rpc.py` - **back-compat shims** re-exporting from - `agentex.protocol.*`. `credentials.py`, `agent_configs.py` - shims - re-exporting from `agentex.config.*`. Existing `from agentex.lib...` - imports keep working; new code should import from the canonical paths. - - Other modules (`tracing`, `agent_card`, `fastacp`, `llm_messages`, - `converters`, etc.) stay here โ€” they have heavier transitive deps - (temporal, openai-agents, model_utils/yaml) and aren't slim-safe. - - `/utils/` - Utility functions -- `/examples/` - Example implementations and tutorials -- `/tests/` - Test suites - -### Key Components - -**SDK Architecture:** -- **Client Layer**: HTTP client for AgentEx API (`_client.py`, `resources/`) -- **CLI Layer**: Typer-based command interface (`lib/cli/`) -- **Core Services**: Temporal workflows, adapters, and services (`lib/core/`) -- **FastACP**: Fast Agent Communication Protocol implementation (`lib/sdk/fastacp/`) -- **State Machine**: Workflow state management (`lib/sdk/state_machine/`) - -**Temporal Integration:** -- Workflow definitions in `lib/core/temporal/` -- Activity definitions for different providers -- Worker implementations for running temporal workflows - -**Agent Framework:** -- Manifest-driven agent configuration -- Support for multiple agent types (sync, temporal-based) -- Debugging support with VS Code integration - -### Code Generation -Most SDK code is auto-generated. Manual changes are preserved in: -- `src/agentex/lib/` directory -- `examples/` directory -- Merge conflicts may occur between manual patches and generator changes - -### Key Dependencies -- `temporalio` - Temporal workflow engine -- `typer` - CLI framework -- `pydantic` - Data validation -- `httpx` - HTTP client -- `fastapi` - Web framework -- `ruff` - Linting and formatting -- `pytest` - Testing framework - -### Environment Requirements -- Python 3.12+ required -- Uses Rye for dependency management -- Supports both sync and async client patterns \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md 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 cfe1611d8..8a0b6b4ca 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/adk/CHANGELOG.md b/adk/CHANGELOG.md deleted file mode 100644 index 76e5d8779..000000000 --- a/adk/CHANGELOG.md +++ /dev/null @@ -1,101 +0,0 @@ -# Changelog - -## 0.20.0 (2026-07-16) - -Full Changelog: [agentex-sdk-v0.19.0...agentex-sdk-v0.20.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.19.0...agentex-sdk-v0.20.0) - -### Chores - -* **agentex-sdk:** Synchronize agentex versions - -## 0.19.0 (2026-07-14) - -Full Changelog: [agentex-sdk-v0.18.0...agentex-sdk-v0.19.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.18.0...agentex-sdk-v0.19.0) - -### Chores - -* **agentex-sdk:** Synchronize agentex versions - -## 0.18.0 (2026-07-10) - -Full Changelog: [agentex-sdk-v0.17.0...agentex-sdk-v0.18.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.17.0...agentex-sdk-v0.18.0) - -### Bug Fixes - -* cap openai <2.45 for openai-agents 0.14.x compatibility ([#459](https://github.com/scaleapi/scale-agentex-python/issues/459)) ([14c124d](https://github.com/scaleapi/scale-agentex-python/commit/14c124d363ed964ed8c08e10a95ca3939095ea92)) - - -### Chores - -* **internal:** version bump ([7aeb893](https://github.com/scaleapi/scale-agentex-python/commit/7aeb8937bb794586f7d5931bdc5964d007762b4c)) -* **internal:** version bump ([fcddeea](https://github.com/scaleapi/scale-agentex-python/commit/fcddeea8ef4bdff0a5f7735156c3003166464eac)) - -## 0.17.0 (2026-07-01) - -Full Changelog: [agentex-sdk-v0.16.2...agentex-sdk-v0.17.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.16.2...agentex-sdk-v0.17.0) - -### Chores - -* **agentex-sdk:** Synchronize agentex versions - -## 0.16.2 (2026-06-29) - -Full Changelog: [agentex-sdk-v0.15.0...agentex-sdk-v0.16.2](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.15.0...agentex-sdk-v0.16.2) - -### Bug Fixes - -* **adk:** release streaming buffer repair in sdk ([#449](https://github.com/scaleapi/scale-agentex-python/issues/449)) ([20795cb](https://github.com/scaleapi/scale-agentex-python/commit/20795cb158244767207b6d3758929014bc015bb6)) - -## 0.15.0 (2026-06-24) - -Full Changelog: [agentex-sdk-v0.14.0...agentex-sdk-v0.15.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.14.0...agentex-sdk-v0.15.0) - -### โš  BREAKING CHANGES - -* **harness:** consolidate the LangGraph harness + remove tracing handler ([#430](https://github.com/scaleapi/scale-agentex-python/issues/430)) - -### Bug Fixes - -* **harness:** harden Claude Code + OpenAI taps and span tracing ([#446](https://github.com/scaleapi/scale-agentex-python/issues/446)) ([5b4359d](https://github.com/scaleapi/scale-agentex-python/commit/5b4359dcf28f390f780215ed954fa52e8cb4dd7c)) - - -### Refactors - -* **harness:** consolidate the LangGraph harness + remove tracing handler ([#430](https://github.com/scaleapi/scale-agentex-python/issues/430)) ([a3fb5ad](https://github.com/scaleapi/scale-agentex-python/commit/a3fb5ad51f6392a48cbb8324f15c9619f10244b6)) - -## 0.14.0 (2026-06-23) - -Full Changelog: [agentex-sdk-v0.13.2...agentex-sdk-v0.14.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.13.2...agentex-sdk-v0.14.0) - -### Features - -* **harness:** public adk facade + docs for the unified harness surface (PR 9) ([#423](https://github.com/scaleapi/scale-agentex-python/issues/423)) ([fa60632](https://github.com/scaleapi/scale-agentex-python/commit/fa60632f9be84315a3fdc627745ae5b605994bd8)) - -## 0.13.2 (2026-06-22) - -Full Changelog: [agentex-sdk-v0.13.1...agentex-sdk-v0.13.2](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.13.1...agentex-sdk-v0.13.2) - -## 0.13.1 (2026-06-17) - -Full Changelog: [agentex-sdk-v0.13.0...agentex-sdk-v0.13.1](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.13.0...agentex-sdk-v0.13.1) - -### Bug Fixes - -* **packaging:** guard agentex-client surface, bump floor, smoke-test wheel install ([#406](https://github.com/scaleapi/scale-agentex-python/issues/406)) ([a5abbb9](https://github.com/scaleapi/scale-agentex-python/commit/a5abbb9669c6ab71c52e60db72676c95c20d840d)) - - -### Documentation - -* drop stale keep_files / dashboard-config comments ([#401](https://github.com/scaleapi/scale-agentex-python/issues/401)) ([23858df](https://github.com/scaleapi/scale-agentex-python/commit/23858df775d0a617c6418eed28f1b68c9bf9ed5c)) - -## 0.13.0 (2026-06-10) - -Full Changelog: [agentex-sdk-v0.12.0...agentex-sdk-v0.13.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.12.0...agentex-sdk-v0.13.0) - -### โš  BREAKING CHANGES - -* **packaging:** release tag scheme changes from v* to -v*. - -### Features - -* **packaging:** introduce slim agentex-client + heavy agentex-sdk split ([bbfb22e](https://github.com/scaleapi/scale-agentex-python/commit/bbfb22eb113dd1f3d5ddf82b4d377895f5ae5466)) diff --git a/adk/README.md b/adk/README.md deleted file mode 100644 index 206ba993b..000000000 --- a/adk/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# agentex-sdk - -The Agent Development Kit (ADK) overlay for the Agentex API. - -## What's in here - -This package ships everything under `agentex.lib.*`: - -- **ACP server** (`agentex.lib.sdk.fastacp`) โ€” FastAPI-based agent control plane. -- **Temporal workflows** (`agentex.lib.core.temporal`) โ€” durable agent execution. -- **CLI** (`agentex.lib.cli`) โ€” `agentex init`, `agentex run`, deploy helpers. -- **LLM provider integrations** (`agentex.lib.adk.providers`, `agentex.lib.core.temporal.plugins`) โ€” OpenAI Agents, Claude Agent SDK, pydantic-ai, langgraph, litellm. -- **Observability** (`agentex.lib.core.tracing`, `agentex.lib.core.observability`) โ€” SGP, Datadog, OpenTelemetry tracing processors. - -## Installation - -```sh -pip install agentex-sdk -``` - -This automatically pulls in [`agentex-client`](../) (the slim Stainless-generated REST client) so `from agentex import Agentex, AsyncAgentex` works the same as before. - -## When to use this vs `agentex-client` - -- **`agentex-sdk`** โ€” you're authoring agents. Pulls everything: ACP server, Temporal, MCP, LLM providers, observability, CLI. ~37 deps. -- **`agentex-client`** โ€” you only need to call the Agentex REST API. No agent authoring, no Temporal workflows, no FastACP server, no provider integrations. 6 deps. - -The two packages contribute disjoint files to the `agentex.*` namespace โ€” `agentex/lib/*` ships only from `agentex-sdk`. - -## Repo layout - -This package is hand-authored and lives at `adk/` inside [scaleapi/scale-agentex-python](https://github.com/scaleapi/scale-agentex-python). Stainless codegen never touches `adk/**` โ€” it's outside the generated surface. The sibling `agentex-client` package lives at the repo root and IS Stainless-generated. - -The wheel source is assembled from `src/agentex/lib/**` by `adk/hatch_build.py`. diff --git a/adk/docs/harness.md b/adk/docs/harness.md deleted file mode 100644 index 62094d469..000000000 --- a/adk/docs/harness.md +++ /dev/null @@ -1,206 +0,0 @@ -# Unified Harness Surface - -The unified harness surface gives every agent harness (pydantic-ai, LangGraph, OpenAI Agents, and future parsers) a single, shared path to streaming, message persistence, and tracing. The Agentex `StreamTaskMessage*` event stream is the canonical wire format. A harness tap produces that stream once; the shared machinery delivers it and derives spans from it. - -All public names are re-exported from `agentex.lib.adk`: - -```python -from agentex.lib.adk import ( - UnifiedEmitter, - SpanTracer, - TurnUsage, - TurnResult, - HarnessTurn, - StreamTaskMessage, - OpenSpan, - CloseSpan, - SpanSignal, -) -``` - -The implementation lives at `src/agentex/lib/core/harness/`. - ---- - -## The canonical stream: `StreamTaskMessage` - -`StreamTaskMessage` is a union of the four wire-protocol update types: - -``` -StreamTaskMessageStart - opens a content slot (text, reasoning, tool request, ...) -StreamTaskMessageDelta - appends a token/fragment to an open slot -StreamTaskMessageFull - posts a complete message in one shot (tool response, ...) -StreamTaskMessageDone - closes an open slot -``` - -Every harness tap produces a sequence of these. Everything downstream (delivery, tracing) reads the same sequence. - ---- - -## Per-harness taps: `convert__to_agentex_events` - -A tap is an async generator that translates the harness's native event stream into `StreamTaskMessage*` events. The shipped taps are: - -| Harness | Tap function | Exported from | -|---|---|---| -| pydantic-ai | `convert_pydantic_ai_to_agentex_events` | `agentex.lib.adk` | -| LangGraph | `convert_langgraph_to_agentex_events` | `agentex.lib.adk` | -| claude-code | `convert_claude_code_to_agentex_events` | `agentex.lib.adk` | -| codex | `convert_codex_to_agentex_events` | `agentex.lib.adk` | -| OpenAI Agents | `convert_openai_to_agentex_events` | `agentex.lib.adk.providers._modules.sync_provider` | - -Each harness also provides a `HarnessTurn` wrapper that pairs its tap's event stream with usage extraction: `PydanticAITurn`, `LangGraphTurn`, `ClaudeCodeTurn`, `CodexTurn`, and `OpenAITurn`. - ---- - -## `HarnessTurn` protocol - -`HarnessTurn` is the interface a harness turn object must satisfy to plug into `UnifiedEmitter`: - -```python -@runtime_checkable -class HarnessTurn(Protocol): - @property - def events(self) -> AsyncIterator[StreamTaskMessage]: ... - - def usage(self) -> TurnUsage: ... -``` - -`events` is the canonical stream for this turn. `usage()` is valid only after `events` is exhausted (async generators cannot cleanly return a value to the consumer, so usage travels out-of-band). - ---- - -## `TurnUsage` - -Token counts and cost for one turn, harness-independent: - -```python -class TurnUsage(BaseModel): - model: str | None = None - input_tokens: int | None = None - output_tokens: int | None = None - cached_input_tokens: int | None = None - reasoning_tokens: int | None = None - total_tokens: int | None = None - cost_usd: float | None = None - duration_ms: int | None = None - num_llm_calls: int = 0 - num_tool_calls: int = 0 - num_reasoning_blocks: int = 0 -``` - -Field names align with `agentex.lib.core.observability.llm_metrics` for easy conversion. - ---- - -## `UnifiedEmitter` - -`UnifiedEmitter` ties a turn's canonical stream, tracing context, and delivery mode together. Construct one per turn with the task/trace context from the request: - -```python -emitter = UnifiedEmitter( - task_id=params.task.id, - trace_id=params.task.id, # or None to disable tracing - parent_span_id=turn_span.id if turn_span else None, -) -``` - -**Tracing is on by default** when `trace_id` is provided. To disable it explicitly, pass `tracer=False`. To inject a custom `SpanTracer` (e.g. in tests), pass it as `tracer=`. - -### Delivery mode 1: `yield_turn` (sync HTTP ACP) - -For sync ACP agents that return events directly over the HTTP response: - -```python -@acp.on_message_send -async def handle(params): - turn = MyHarnessTurn(params) # implements HarnessTurn - async for event in emitter.yield_turn(turn): - yield event -``` - -`yield_turn` forwards each event to the caller and traces spans as a side effect. It is a passthrough when `tracer` is `None`. - -### Delivery mode 2: `auto_send_turn` (async/Temporal) - -For async or Temporal agents that push to the task stream via Redis: - -```python -result: TurnResult = await emitter.auto_send_turn(turn, created_at=workflow.now()) -``` - -`auto_send_turn` drives `adk.streaming` contexts for every message in the stream, derives and records spans, and returns a `TurnResult` with the final text and usage. Pass `created_at` under Temporal to back-date message timestamps deterministically. - ---- - -## `TurnResult` - -```python -class TurnResult(BaseModel): - final_text: str = "" - usage: TurnUsage = TurnUsage() -``` - -Returned by `auto_send_turn`. `final_text` is the last text segment of the turn (multi-step runs return only the final segment, matching `stream_langgraph_events` / `stream_pydantic_ai_events` semantics). - ---- - -## Tracing: span derivation - -Spans are derived from the canonical stream by `SpanDeriver` (pure, no `adk` dependency) and dispatched to `adk.tracing` by `SpanTracer`. The mapping: - -- `StreamTaskMessageStart(ToolRequestContent)` + `StreamTaskMessageDone` on that index -> tool span open (keyed by `tool_call_id`) -- `StreamTaskMessageFull(ToolResponseContent)` whose `tool_call_id` was opened -> tool span close -- `StreamTaskMessageFull(ToolRequestContent)` (harnesses that emit tool calls as Full) -> opens a tool span; matching `Full(ToolResponseContent)` closes it -- `StreamTaskMessageStart(ReasoningContent)` + `StreamTaskMessageDone` -> reasoning span - -`SpanTracer` is `SpanDeriver`'s consumer. You can inject a custom `SpanTracer` via `UnifiedEmitter(tracer=)` for advanced use or testing. - ---- - -## Usage examples by channel - -### Sync ACP (`yield_turn`) - -Build the harness's `HarnessTurn` wrapper and iterate `emitter.yield_turn(turn)` โ€” the emitter forwards each event to the caller and traces spans as a side effect: - -```python -import agentex.lib.adk as adk -from agentex.lib.adk import UnifiedEmitter, ClaudeCodeTurn - -@acp.on_message_send -async def handle(params): - task_id = params.task.id - async with adk.tracing.span(trace_id=task_id, name="message", ...) as turn_span: - emitter = UnifiedEmitter( - task_id=task_id, - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - ) - turn = ClaudeCodeTurn(claude_code_stream) # any HarnessTurn - async for event in emitter.yield_turn(turn): - yield event -``` - -Every harness follows the same shape โ€” swap `ClaudeCodeTurn` for `PydanticAITurn`, `LangGraphTurn`, `CodexTurn`, or `OpenAITurn` and feed it that harness's native stream. - -### Async Temporal (auto-send) - -```python -from agentex.lib.adk import UnifiedEmitter - -emitter = UnifiedEmitter( - task_id=task_id, - trace_id=task_id, - parent_span_id=parent_span_id, -) -result = await emitter.auto_send_turn(turn, created_at=workflow.now()) -# result.final_text โ€” last text segment -# result.usage โ€” TurnUsage (tokens, cost, ...) -``` - ---- - -## Migration - -- [Migrating to `agentex-client` 0.16.0 / `agentex-sdk` 0.15.0](./migration-0.16.0.md) โ€” removed LangGraph/Pydantic-AI tracing handlers (tracing is now derived from the canonical stream), private `_modules` path moves, the OpenAI harness facade relocation, and the new `run_turn` Temporal entry point. diff --git a/adk/docs/migration-0.16.0.md b/adk/docs/migration-0.16.0.md deleted file mode 100644 index b76da55ba..000000000 --- a/adk/docs/migration-0.16.0.md +++ /dev/null @@ -1,272 +0,0 @@ -# Migration Guide โ€” `agentex-client` 0.16.0 / `agentex-sdk` 0.15.0 - -This release consolidates the LangGraph, Pydantic-AI, and OpenAI Agents harnesses -onto the **unified harness surface** (`UnifiedEmitter` + `SpanDeriver`), introduces -`run_turn` as the single Temporal entry point for OpenAI Agents, renders -hosted/server-side tool calls in the Temporal streaming model, and ships new CLI -init templates. - -Most consumers only need to act on **section 1** (removed tracing handlers). -Sections 2โ€“3 only matter if you import private modules. Section 4 lists the new, -opt-in capabilities. Section 5 documents the defect fixes shipped on top of the -release. - ---- - -## 1. Tracing handlers removed (LangGraph + Pydantic-AI) โ€” **action required** - -The bespoke tracing callback handlers are **gone** from the public -`agentex.lib.adk` surface: - -| Removed | | -|---|---| -| `agentex.lib.adk.create_langgraph_tracing_handler` | + class `AgentexLangGraphTracingHandler` | -| `agentex.lib.adk.create_pydantic_ai_tracing_handler` | + class `AgentexPydanticAITracingHandler` | - -Span tracing is now **derived automatically** from the canonical -`StreamTaskMessage*` stream by `UnifiedEmitter`. You no longer construct or pass a -callback handler โ€” you wrap the run in the harness `*Turn` and drive delivery -through the emitter, and spans fall out of the stream. - -### LangGraph - -**Before** - -```python -from agentex.lib import adk - -handler = adk.create_langgraph_tracing_handler( - trace_id=trace_id, - parent_span_id=parent_span_id, -) -result = await graph.ainvoke(state, config={"callbacks": [handler]}) -``` - -**After** - -```python -from agentex.lib.adk import stream_langgraph_events # facade name unchanged - -# Streaming delivery + tracing are handled for you; no callbacks wiring. -async for event in stream_langgraph_events(graph, state, ...): - ... -``` - -or, when you own the emitter directly: - -```python -from agentex.lib.adk import LangGraphTurn -from agentex.lib.core.harness import UnifiedEmitter - -emitter = UnifiedEmitter(...) -await emitter.auto_send_turn(LangGraphTurn(...)) # or: emitter.yield_turn(...) -``` - -### Pydantic-AI - -**Before** - -```python -handler = adk.create_pydantic_ai_tracing_handler(trace_id=..., parent_span_id=...) -``` - -**After** - -```python -from agentex.lib.adk import PydanticAITurn, stream_pydantic_ai_events -from agentex.lib.core.harness import UnifiedEmitter - -# Wrap in PydanticAITurn and drive UnifiedEmitter.yield_turn / auto_send_turn. -await UnifiedEmitter(...).auto_send_turn(PydanticAITurn(...)) -``` - -The `agentex init` templates were migrated to this pattern. If you scaffolded -from an older template, regenerate (or diff against a fresh template) for the -canonical shape. - ---- - -## 2. Private `_modules` import paths changed โ€” **only if you import privates** - -Each harness now exposes exactly `__sync.py` + `__turn.py` under -`agentex.lib.adk._modules`. Several private modules were deleted and their -functions relocated. If you imported the **public facade names** from -`agentex.lib.adk`, **nothing changes**. Repoint only if you reached into the -private modules directly: - -| Old (deleted) private import | New location | Public facade (unchanged) | -|---|---|---| -| `_modules._langgraph_async.stream_langgraph_events` | `_modules._langgraph_turn` | `adk.stream_langgraph_events` | -| `_modules._langgraph_messages.emit_langgraph_messages` | `_modules._langgraph_sync` | `adk.emit_langgraph_messages` | -| `_modules._langgraph_tracing.*` | **removed** (see ยง1) | โ€” | -| `_modules._pydantic_ai_async.stream_pydantic_ai_events` | `_modules._pydantic_ai_turn` | `adk.stream_pydantic_ai_events` | -| `_modules._pydantic_ai_tracing.*` | **removed** (see ยง1) | โ€” | - -โœ… These facade names are unchanged and keep working: -`stream_langgraph_events`, `emit_langgraph_messages`, -`convert_langgraph_to_agentex_events`, `LangGraphTurn`, -`stream_pydantic_ai_events`, `convert_pydantic_ai_to_agentex_events`, -`PydanticAITurn`. - ---- - -## 3. OpenAI harness moved into `adk/_modules` + facade export - -The OpenAI Agents harness now lives alongside the others: - -- `OpenAITurn`, `openai_usage_to_turn_usage` โ†’ `agentex.lib.adk._modules._openai_turn` -- `convert_openai_to_agentex_events` โ†’ `agentex.lib.adk._modules._openai_sync` - -New **public** facade exports (prefer these): - -```python -from agentex.lib.adk import ( - OpenAITurn, - convert_openai_to_agentex_events, - openai_usage_to_turn_usage, -) -``` - -Back-compat shims remain at -`agentex.lib.adk.providers._modules.{openai_turn,sync_provider}` **for one -release** โ€” migrate to the facade names before the next minor. - ---- - -## 4. New capabilities (opt-in, no migration required) - -- **`run_turn` โ€” unified Temporal entry point for OpenAI Agents.** - - ```python - from agentex.lib.core.temporal.plugins.openai_agents import run_turn, OpenAIAgentsTurnResult - - result = await run_turn( - agent, input, - task_id=task_id, - trace_id=trace_id, - parent_span_id=parent_span_id, - ) - result.final_output # raw SDK final_output - result.usage # normalized TurnUsage for the turn span - ``` - - It emits each tool call exactly once (the streaming model is the sole - tool-**request** emitter; hooks emit tool **responses**), traces per-tool spans, - normalizes token usage, and drains orphaned tool spans in a `finally` block if - the run terminates mid-tool. Existing `TemporalStreamingHooks` callers keep - working โ€” `run_turn` is additive. If you pass your own `hooks` subclass, also - set `emit_tool_requests=False` and forward `trace_id` / `parent_span_id` - yourself (they are only auto-applied to the default hooks). - -- **Hosted / server-side tool rendering** in `TemporalStreamingModel`: - web_search, file_search, code_interpreter, image_generation, server-side mcp, - computer, and local_shell calls now surface as ToolRequest/ToolResponse pairs. - -- **New CLI init templates:** `default` / `sync` / `temporal` flavors of - `claude-code` and `codex`, plus `default-openai-agents`. - ---- - -## 5. Defect fixes shipped with this migration - -These fixes harden the newly-added sync OpenAI converter -(`convert_openai_to_agentex_events` / `OpenAITurn`) and the Temporal hosted-tool -path. No API change โ€” behavior only. - -1. **Malformed tool arguments no longer abort the turn.** The converter now - parses raw tool-call arguments through a defensive helper - (`_safe_parse_arguments`): a non-decodable string is preserved under `raw` - and a non-dict JSON value under `value`, instead of raising `JSONDecodeError` - and killing the run before later output is delivered. This matches the - Temporal streaming model's existing fallback. - -2. **Reasoning messages are closed.** Completed reasoning content/summary items - now emit a matching `StreamTaskMessageDone`. Previously the `Done` was - skipped, so `UnifiedEmitter.auto_send` never released the context and the - reasoning span could be marked incomplete (reasoning-model output appeared to - hang). - -3. **Text no longer collides with reasoning.** Every new text `item_id` now - reserves a fresh message index (matching the increment-then-use convention of - the reasoning/tool paths). Previously the first text item reused the current - index, so on reasoning-model streams the final answer could overwrite the - reasoning message, duplicate a `Start`, or route deltas into the wrong context. - -4. **Hosted-tool response shape aligned.** Hosted/server-side tool responses in - `TemporalStreamingModel` now emit `content` as a plain string, matching the - function-tool response path (`on_tool_end`) so hosted and function tools - render identically within the same flow. - -5. **Reasoning text now appears in derived spans.** `SpanDeriver` opened reasoning - spans with empty input and closed them with `output=None`, so reasoning/thinking - text never reached the trace (spans showed blank โ€” read as "0 reasoning traces"). - It now accumulates the `ReasoningContentDelta` / `ReasoningSummaryDelta` text (and - any text seeded on the Start content) and records it as the span output. Affects - every harness that streams reasoning, including the Claude Code tap. - -6. **Claude Code: no more duplicate text messages.** The `stream-json` converter - deduped streamed-vs-materialized blocks by numeric block index and reset that - state after every materialized `assistant` envelope. A single streamed message - that materializes as several envelopes (thinking, then text) lost the dedup - marker between envelopes and re-emitted the text. Dedup is now **content-based** - (match the streamed block's text, consume once), which a numeric index cannot do - reliably. - -> Action: if you adopted `OpenAITurn` for **reasoning models** (o1/o3/gpt-5) on -> the sync path before these fixes, upgrade โ€” fixes 2 and 3 are required for -> correct reasoning rendering. Claude Code agents on the unified harness tap should -> upgrade for fixes 5 and 6. - ---- - -## 6. Legacy Temporal `claude_agents` plugin โ†’ unified harness tap - -`agentex.lib.core.temporal.plugins.claude_agents` (`run_claude_agent_activity`, -`create_streaming_hooks`, `TemporalStreamingHooks`, `ClaudeMessageHandler`) is the -**original** Claude Code integration: it drives the Python `claude-agent-sdk` -directly and hand-rolls its own streaming + tracing. It is **superseded** by the -unified harness tap and slated for removal in a future release. It still works -today, so this migration is **recommended, not yet required** โ€” but new Claude Code -agents should use the tap, and existing ones should plan to move. - -Why migrate: the tap routes Claude Code through the same canonical -`StreamTaskMessage*` stream as every other harness, so it gets central span -derivation (tool **and** reasoning spans), the single delivery path -(`UnifiedEmitter`), and fixes like the two above for free. The legacy plugin does -not derive reasoning spans at all and duplicates the streaming/tracing logic. - -**Before โ€” legacy plugin activity:** - -```python -from agentex.lib.core.temporal.plugins.claude_agents import run_claude_agent_activity - -# In the workflow: -result = await workflow.execute_activity( - run_claude_agent_activity, - args=[prompt, workspace_path, allowed_tools, ...], - start_to_close_timeout=..., -) -``` - -**After โ€” unified harness tap.** Run the CLI yourself (`claude -p --output-format -stream-json --include-partial-messages`), wrap its stdout in `ClaudeCodeTurn`, and -deliver through `UnifiedEmitter`: - -```python -from agentex.lib.adk import ClaudeCodeTurn, UnifiedEmitter - -# `stdout_lines` is an async iterator of the CLI's stdout lines (raw JSON strings -# or pre-parsed dicts) โ€” e.g. read from sandbox.exec() / a subprocess. -turn = ClaudeCodeTurn(stdout_lines) - -emitter = UnifiedEmitter(task_id=task_id, trace_id=trace_id, parent_span_id=parent_span_id) -result = await emitter.auto_send_turn(turn, created_at=workflow.now()) -# result.final_text โ€” last text segment -# result.usage โ€” TurnUsage (tokens, cost, num_reasoning_blocks, ...) -``` - -The golden agent is the reference implementation -(`teams/sgp/agents/golden_agent/project/harness/`): it spawns the CLI in a sandbox, -yields stdout lines into `ClaudeCodeTurn`, and drives `auto_send_turn`. Known -remaining consumers to migrate: the `090_claude_agents_sdk_mvp` tutorial and the -`eval_dashboard_agent`. diff --git a/adk/hatch_build.py b/adk/hatch_build.py deleted file mode 100644 index 8baadfe5b..000000000 --- a/adk/hatch_build.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Builds the agentex/lib force-include map per-file so test files can be pruned -โ€” force-include ignores `exclude` (hatchling #1395).""" - -from __future__ import annotations - -import os - -from hatchling.builders.hooks.plugin.interface import BuildHookInterface - -_SKIP_DIRS = {"__pycache__", "tests"} -_SKIP_NAMES = {"conftest.py", "pytest.ini", "run_tests.py"} -# Floor below the ~333 shippable files: a collapse means the walk broke โ€” fail -# loud rather than ship a near-empty wheel. -_MIN_FILES = 320 - - -def _is_test_file(name: str) -> bool: - return name in _SKIP_NAMES or (name.startswith("test_") and name.endswith(".py")) - - -class CustomBuildHook(BuildHookInterface): - PLUGIN_NAME = "custom" - - def initialize(self, version: str, build_data: dict) -> None: # noqa: ARG002 - lib_root = os.path.normpath(os.path.join(self.root, "..", "src", "agentex", "lib")) - force_include = build_data.setdefault("force_include", {}) - collected = 0 - for dirpath, dirnames, filenames in os.walk(lib_root): - dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS] - for name in filenames: - if _is_test_file(name): - continue - src = os.path.join(dirpath, name) - rel = os.path.relpath(src, lib_root) - force_include[src] = os.path.join("agentex", "lib", rel) - collected += 1 - if collected < _MIN_FILES: - raise RuntimeError( - f"agentex/lib force-include collected only {collected} files " - f"(expected >= {_MIN_FILES}); aborting build." - ) diff --git a/adk/pyproject.toml b/adk/pyproject.toml deleted file mode 100644 index 5fca1d619..000000000 --- a/adk/pyproject.toml +++ /dev/null @@ -1,109 +0,0 @@ -[project] -# Hand-authored ADK overlay for agentex. This package contributes only -# `agentex/lib/*` to the agentex.* namespace; the REST client surface -# (agentex/{__init__.py, _*.py, types/, resources/}) ships from the slim -# sibling package `agentex-client` which is pinned as a runtime dep. -name = "agentex-sdk" -version = "0.20.0" -description = "Agent Development Kit (ADK) overlay for the Agentex API โ€” FastACP server, Temporal workflows, LLM provider integrations, observability" -license = "Apache-2.0" -authors = [ - { name = "Agentex", email = "roxanne.farhad@scale.com" }, -] -readme = "README.md" - -dependencies = [ - # Co-released in lockstep; floor-only by design โ€” a ceiling would - # eventually exclude the co-versioned slim (release-please can't bump it). - "agentex-client>=0.13.0", - # CLI surface (agentex.lib.cli.*, agentex.lib.sdk.config.*) - "typer>=0.16,<0.17", - "questionary>=2.0.1,<3", - "rich>=13.9.2,<14", - "yaspin>=3.1.0", - "pyyaml>=6.0.2,<7", - "python-on-whales>=0.73.0,<0.74", - "kubernetes>=25.0.0,<36.0.0", - "jsonref>=1.1.0,<2", - "jsonschema>=4.23.0,<5", - "jinja2>=3.1.3,<4", - "watchfiles>=0.24.0,<1.0", - # ACP server (FastAPI app surface) - "fastapi>=0.115.0", - "starlette>=0.49.1", - "uvicorn>=0.31.1", - "aiohttp>=3.10.10,<4", - # Temporal workflows - "temporalio>=1.26.0,<2", - "cloudpickle>=3.1.1", - # Async streaming infra - "redis>=5.2.0,<8", - # LLM provider integrations - "litellm>=1.83.7,<2", - "openai-agents>=0.14.3,<0.15", - # Cap <2.45: openai 2.45.0 makes InputTokensDetails.cache_write_tokens a - # required field, but openai-agents 0.14.x still builds - # InputTokensDetails(cached_tokens=0) (agents/usage.py), so every - # Runner.run_streamed raises a pydantic ValidationError at context setup. - # openai-agents 0.14.8 is the latest release, so there is no newer version - # to bump to; drop this ceiling once openai-agents ships a fix. - # litellm now supports openai 2.x (issue #13711 resolved: https://github.com/BerriAI/litellm/issues/13711) - "openai>=2.2,<2.45", - "claude-agent-sdk>=0.1.0", - "pydantic-ai-slim>=1.0,<2", - "langgraph-checkpoint>=2.0.0", - "scale-gp>=0.1.0a59", - "scale-gp-beta>=0.2.0", - "mcp>=1.4.1", - # Observability - "ddtrace>=3.13.0", - "opentelemetry-api>=1.20.0", - "opentelemetry-sdk>=1.20.0", - "json_log_formatter>=1.1.1", -] - -# agentex/lib/* uses `from typing import override` (3.12+) in 19 files. -# The slim agentex-client keeps 3.11 support. -requires-python = ">= 3.12,<4" -classifiers = [ - "Typing :: Typed", - "Intended Audience :: Developers", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", - "Operating System :: OS Independent", - "Topic :: Software Development :: Libraries :: Python Modules", - "License :: OSI Approved :: Apache Software License", -] - -[project.urls] -Homepage = "https://github.com/scaleapi/scale-agentex-python" -Repository = "https://github.com/scaleapi/scale-agentex-python" - -[project.scripts] -agentex = "agentex.lib.cli.commands.main:app" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -# Ship only agentex/lib/*, pulled in from the parent repo's `src/agentex/lib` -# tree. The rest of agentex.* (the Stainless-generated client) ships from the -# sibling agentex-client package, which this package pins as a runtime dep. -# Stainless explicitly preserves `src/agentex/lib/` across codegen (per -# CONTRIBUTING.md), so it's safe to keep the source where it is. -[tool.hatch.build.targets.wheel] -bypass-selection = true - -# Builds the ../src/agentex/lib force-include map per-file (see hatch_build.py) -# so test files can be pruned โ€” force-include ignores `exclude` (hatchling #1395). -[tool.hatch.build.targets.wheel.hooks.custom] -path = "hatch_build.py" - -# Sdist deferred: hatchling can't represent the wheel's ../src/agentex/lib -# force-include in an sdist include list. CI + bin/publish-pypi pass --wheel. -[tool.hatch.build.targets.sdist] -include = [ - "/pyproject.toml", - "/README.md", -] diff --git a/bin/check-release-environment b/bin/check-release-environment index 9168b4fce..b845b0f4c 100644 --- a/bin/check-release-environment +++ b/bin/check-release-environment @@ -1,21 +1,9 @@ #!/usr/bin/env bash -# This script is run by Release Doctor to validate the release environment. -# After the dual-package split (slim agentex-client + heavy agentex-sdk), -# both PyPI tokens must be present โ€” one for each package name. If only -# PYPI_TOKEN is set, fall back to using it for both (back-compat for legacy -# single-token setups, which forces an account-scoped token). - errors=() -# Heavy `agentex-sdk` token (existing PyPI name). -if [ -z "${AGENTEX_PYPI_TOKEN}" ] && [ -z "${PYPI_TOKEN}" ]; then - errors+=("The AGENTEX_PYPI_TOKEN secret has not been set (and no fallback PYPI_TOKEN). Add it in repo secrets so the heavy 'agentex-sdk' package can be published.") -fi - -# Slim `agentex-client` token (new PyPI name). -if [ -z "${AGENTEX_CLIENT_PYPI_TOKEN}" ] && [ -z "${PYPI_TOKEN}" ]; then - errors+=("The AGENTEX_CLIENT_PYPI_TOKEN secret has not been set (and no fallback PYPI_TOKEN). Add it in repo secrets so the slim 'agentex-client' package can be published. Falling back to PYPI_TOKEN requires an account-scoped token.") +if [ -z "${PYPI_TOKEN}" ]; then + errors+=("The PYPI_TOKEN secret has not been set. Please set it in either this repository's secrets or your organization secrets.") fi lenErrors=${#errors[@]} diff --git a/bin/publish-pypi b/bin/publish-pypi index 7107d194e..e72ca2fa4 100644 --- a/bin/publish-pypi +++ b/bin/publish-pypi @@ -1,54 +1,7 @@ #!/usr/bin/env bash -# Publish only the package requested by the component tag. Manual dispatches can -# set PYPI_PACKAGE=all to publish both packages; in that case publish slim -# before heavy because the heavy package depends on the slim package. - set -eux - rm -rf dist -# --wheel: the heavy's cross-dir force-include can't build via sdist. -uv build --all-packages --wheel - -publish_client() { - uv publish --check-url https://pypi.org/simple/ --token="${AGENTEX_CLIENT_PYPI_TOKEN:-${PYPI_TOKEN:-}}" dist/agentex_client-*.whl -} - -publish_sdk() { - uv publish --check-url https://pypi.org/simple/ --token="${AGENTEX_PYPI_TOKEN:-${PYPI_TOKEN:-}}" dist/agentex_sdk-*.whl -} - -package="${PYPI_PACKAGE:-}" - -if [ -z "$package" ]; then - tag_name="${GITHUB_REF_NAME:-}" - if [ -z "$tag_name" ] && [[ "${GITHUB_REF:-}" == refs/tags/* ]]; then - tag_name="${GITHUB_REF#refs/tags/}" - fi - - case "$tag_name" in - agentex-client-v*) package="agentex-client" ;; - agentex-sdk-v*) package="agentex-sdk" ;; - *) - echo "Unable to infer package from tag '$tag_name'. Set PYPI_PACKAGE to one of: all, agentex-client, agentex-sdk." >&2 - exit 1 - ;; - esac -fi - -case "$package" in - all) - publish_client - publish_sdk - ;; - agentex-client) - publish_client - ;; - agentex-sdk) - publish_sdk - ;; - *) - echo "Unknown PYPI_PACKAGE '$package'. Expected one of: all, agentex-client, agentex-sdk." >&2 - exit 1 - ;; -esac +mkdir -p dist +uv build +uv publish --token=$PYPI_TOKEN 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 5a68792cc..000000000 --- a/examples/tutorials/00_sync/030_langgraph/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# Tutorial: Sync LangGraph Agent - -This tutorial demonstrates how to build a **synchronous** LangGraph agent on AgentEx -using the **unified harness surface**: - -```python -turn = LangGraphTurn(stream, model=None) -emitter = UnifiedEmitter(task_id=task_id, trace_id=task_id, ...) -async for event in emitter.yield_turn(turn): - yield event -``` - -The `LangGraphTurn` + `UnifiedEmitter` path replaces calling the lower-level -``convert_langgraph_to_agentex_events`` helper directly. - -## Key Concepts - -### Unified Harness - -`LangGraphTurn` implements the `HarnessTurn` protocol: it wraps the raw -LangGraph `astream()` generator and exposes `events` (an async generator of -`TaskMessageUpdate`) and `usage()` (token counts captured from the final -`AIMessage`). - -`UnifiedEmitter.yield_turn(turn)` iterates the turn's events and yields them -to the sync ACP handler unchanged. The same `LangGraphTurn` object can also be -passed to `UnifiedEmitter.auto_send_turn` in the async/temporal channels. - -### AGX1-377 Note - -LangGraph emits tool requests as `StreamTaskMessageFull` events (from "updates" -node outputs). The `SpanDeriver` does not open tool spans from Full events -today; that gap is tracked in AGX1-373. - -## Files - -| File | Description | -|------|-------------| -| `project/acp.py` | ACP server using unified harness (LangGraphTurn + yield_turn) | -| `project/graph.py` | LangGraph state graph (weather example) | -| `project/tools.py` | Tool definitions (weather example) | -| `tests/test_agent.py` | Integration tests | -| `manifest.yaml` | Agent configuration (name: s030-langgraph) | - -## Running Locally - -```bash -agentex agents run -``` - -## Running Tests - -```bash -pytest tests/test_agent.py -v -``` diff --git a/examples/tutorials/00_sync/030_langgraph/manifest.yaml b/examples/tutorials/00_sync/030_langgraph/manifest.yaml deleted file mode 100644 index 9a52a3dce..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 using the unified harness surface (LangGraphTurn + UnifiedEmitter.yield_turn) - - temporal: - enabled: false - - credentials: - - env_var_name: OPENAI_API_KEY - secret_name: openai-api-key - secret_key: api-key - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - - env_var_name: SGP_API_KEY - secret_name: sgp-api-key - secret_key: api-key - - env_var_name: SGP_ACCOUNT_ID - secret_name: sgp-account-id - secret_key: account-id - - env_var_name: SGP_CLIENT_BASE_URL - secret_name: sgp-client-base-url - secret_key: url - -deployment: - image: - repository: "" - tag: "latest" - - global: - agent: - name: "s030-langgraph" - description: "A sync LangGraph agent using the unified harness surface" - replicaCount: 1 - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" diff --git a/examples/tutorials/00_sync/030_langgraph/project/__init__.py b/examples/tutorials/00_sync/030_langgraph/project/__init__.py 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 e42b0f4ea..000000000 --- a/examples/tutorials/00_sync/030_langgraph/project/acp.py +++ /dev/null @@ -1,107 +0,0 @@ -"""ACP handler for the sync LangGraph agent. - -Uses the unified harness surface: ``LangGraphTurn`` wraps the LangGraph -``astream()`` generator, and ``UnifiedEmitter.yield_turn`` converts it into -the AgentEx ``TaskMessageUpdate`` event stream expected by the sync ACP. - -Properties of the unified surface: -- Tracing is wired through the tracing manager (no bespoke handler boilerplate). -- No manual text-delta accumulation for the span output. -- Tool calls are emitted as ``StreamTaskMessageFull`` (not Start+Delta+Done) - via the same code path as the async/temporal channels. -- Usage data (token counts) is captured on the ``LangGraphTurn`` object and - can be read after the turn completes. - -AGX1-377 note: LangGraph emits tool requests as ``StreamTaskMessageFull`` -events (from "updates"). The ``SpanDeriver`` does not open tool spans from -Full events today; that gap is tracked in AGX1-373. -""" - -from __future__ import annotations - -import os -from typing import AsyncGenerator - -from dotenv import load_dotenv - -load_dotenv() - -import agentex.lib.adk as adk -from project.graph import create_graph -from agentex.lib.types.acp import SendMessageParams -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.lib.sdk.fastacp.fastacp import FastACP -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.types.task_message_delta import TextDelta -from agentex.types.task_message_update import TaskMessageUpdate -from agentex.types.task_message_content import TaskMessageContent -from agentex.lib.adk._modules._langgraph_turn import LangGraphTurn -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config - -logger = make_logger(__name__) - -add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SGP_API_KEY", ""), - sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - ) -) - -acp = FastACP.create(acp_type="sync") - -_graph = None - - -async def get_graph(): - """Get or create the compiled graph instance.""" - global _graph - if _graph is None: - _graph = await create_graph() - return _graph - - -@acp.on_message_send -async def handle_message_send( - params: SendMessageParams, -) -> TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]: - """Handle incoming messages, streaming tokens and tool calls via unified harness.""" - graph = await get_graph() - - task_id = params.task.id - user_message = params.content.content - - logger.info(f"Processing message for task {task_id}") - - async with adk.tracing.span( - trace_id=task_id, - task_id=task_id, - name="message", - input={"message": user_message}, - data={"__span_type__": "AGENT_WORKFLOW"}, - ) as turn_span: - stream = graph.astream( - {"messages": [{"role": "user", "content": user_message}]}, - config={"configurable": {"thread_id": task_id}}, - stream_mode=["messages", "updates"], - ) - - turn = LangGraphTurn(stream, model=None) - emitter = UnifiedEmitter( - task_id=task_id, - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - ) - - final_text = "" - async for event in emitter.yield_turn(turn): - # Accumulate text deltas so the span's final_output is the assistant - # text (matching the async tutorial), not the usage metrics. - delta = getattr(event, "delta", None) - if isinstance(delta, TextDelta) and delta.text_delta: - final_text += delta.text_delta - yield event - - if turn_span: - turn_span.output = {"final_output": final_text, "usage": turn.usage().model_dump()} diff --git a/examples/tutorials/00_sync/030_langgraph/project/graph.py b/examples/tutorials/00_sync/030_langgraph/project/graph.py deleted file mode 100644 index 6709719e5..000000000 --- a/examples/tutorials/00_sync/030_langgraph/project/graph.py +++ /dev/null @@ -1,67 +0,0 @@ -"""LangGraph graph definition for the 030_langgraph sync agent. - -Identical to ``030_langgraph/project/graph.py`` โ€” the graph definition is not -affected by the harness migration. Only ``acp.py`` changes. -""" - -from __future__ import annotations - -from typing import Any, Annotated -from datetime import datetime -from typing_extensions import TypedDict - -from langgraph.graph import START, StateGraph -from langchain_openai import ChatOpenAI -from langgraph.prebuilt import ToolNode, tools_condition -from langchain_core.messages import SystemMessage -from langgraph.graph.message import add_messages - -from project.tools import TOOLS -from agentex.lib.adk import create_checkpointer - -MODEL_NAME = "gpt-5" -SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. - -Current date and time: {timestamp} - -Guidelines: -- Be concise and helpful -- Use tools when they would help answer the user's question -- If you're unsure, ask clarifying questions -- Always provide accurate information -""" - - -class AgentState(TypedDict): - """State schema for the agent graph.""" - - messages: Annotated[list[Any], add_messages] - - -async def create_graph(): - """Create and compile the agent graph with checkpointer.""" - llm = ChatOpenAI( - model=MODEL_NAME, - reasoning={"effort": "high", "summary": "auto"}, - ) - llm_with_tools = llm.bind_tools(TOOLS) - - checkpointer = await create_checkpointer() - - def agent_node(state: AgentState) -> dict[str, Any]: - """Process the current state and generate a response.""" - messages = state["messages"] - if not messages or not isinstance(messages[0], SystemMessage): - system_content = SYSTEM_PROMPT.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")) - messages = [SystemMessage(content=system_content)] + messages - response = llm_with_tools.invoke(messages) - return {"messages": [response]} - - builder = StateGraph(AgentState) - builder.add_node("agent", agent_node) - builder.add_node("tools", ToolNode(tools=TOOLS)) - builder.add_edge(START, "agent") - builder.add_conditional_edges("agent", tools_condition, "tools") - builder.add_edge("tools", "agent") - - return builder.compile(checkpointer=checkpointer) diff --git a/examples/tutorials/00_sync/030_langgraph/project/tools.py b/examples/tutorials/00_sync/030_langgraph/project/tools.py deleted file mode 100644 index b3e5dba34..000000000 --- a/examples/tutorials/00_sync/030_langgraph/project/tools.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Tool definitions for the 030_langgraph sync agent.""" - -from langchain_core.tools import Tool - - -def get_weather(city: str) -> str: - """Get the current weather for a city. - - Args: - city: The name of the city to get weather for. - - Returns: - A string describing the weather conditions. - """ - return f"The weather in {city} is sunny and 72ยฐF" - - -weather_tool = Tool( - name="get_weather", - func=get_weather, - description="Get the current weather for a city. Input should be a city name.", -) - -TOOLS = [weather_tool] diff --git a/examples/tutorials/00_sync/030_langgraph/pyproject.toml b/examples/tutorials/00_sync/030_langgraph/pyproject.toml deleted file mode 100644 index 33bea16b5..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 using the unified harness surface" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "agentex-sdk", - "scale-gp", - "langgraph", - "langchain-openai", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "httpx", - "black", - "isort", - "flake8", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] - -[tool.black] -line-length = 88 -target-version = ['py312'] - -[tool.isort] -profile = "black" -line_length = 88 diff --git a/examples/tutorials/00_sync/030_langgraph/tests/test_agent.py b/examples/tutorials/00_sync/030_langgraph/tests/test_agent.py deleted file mode 100644 index dabd83e76..000000000 --- a/examples/tutorials/00_sync/030_langgraph/tests/test_agent.py +++ /dev/null @@ -1,144 +0,0 @@ -""" -Tests for the sync harness LangGraph agent. - -Validates the unified harness surface (LangGraphTurn + UnifiedEmitter.yield_turn) -end-to-end against a live AgentEx server. - -Configuration: -- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) -- AGENT_NAME: Name of the agent to test (default: s030-langgraph) -""" - -import os - -import pytest -from test_utils.sync import validate_text_in_string, collect_streaming_response - -from agentex import Agentex -from agentex.types import TextContent, TextContentParam -from agentex.types.agent_rpc_params import ParamsCreateTaskRequest, ParamsSendMessageRequest -from agentex.lib.sdk.fastacp.base.base_acp_server import uuid - -AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") -AGENT_NAME = os.environ.get("AGENT_NAME", "s030-langgraph") - - -@pytest.fixture -def client(): - return Agentex(base_url=AGENTEX_API_BASE_URL) - - -@pytest.fixture -def agent_name(): - return AGENT_NAME - - -@pytest.fixture -def agent_id(client, agent_name): - agents = client.agents.list() - for agent in agents: - if agent.name == agent_name: - return agent.id - raise ValueError(f"Agent with name {agent_name} not found.") - - -class TestNonStreamingMessages: - def test_send_simple_message(self, client: Agentex, agent_name: str): - response = client.agents.send_message( - agent_name=agent_name, - params=ParamsSendMessageRequest( - content=TextContentParam( - author="user", - content="Hello! What can you help me with?", - type="text", - ) - ), - ) - result = response.result - assert result is not None - assert len(result) >= 1 - - def test_tool_calling(self, client: Agentex, agent_name: str): - response = client.agents.send_message( - agent_name=agent_name, - params=ParamsSendMessageRequest( - content=TextContentParam( - author="user", - content="What's the weather in San Francisco?", - type="text", - ) - ), - ) - result = response.result - assert result is not None - assert len(result) >= 1 - - def test_multiturn_conversation(self, client: Agentex, agent_name: str, agent_id: str): - task_response = client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) - task = task_response.result - assert task is not None - - response1 = client.agents.send_message( - agent_name=agent_name, - params=ParamsSendMessageRequest( - content=TextContentParam( - author="user", - content="My name is Alice. Remember that.", - type="text", - ), - task_id=task.id, - ), - ) - assert response1.result is not None - - response2 = client.agents.send_message( - agent_name=agent_name, - params=ParamsSendMessageRequest( - content=TextContentParam( - author="user", - content="What is my name?", - type="text", - ), - task_id=task.id, - ), - ) - assert response2.result is not None - for message in response2.result: - if isinstance(message.content, TextContent): - validate_text_in_string("alice", message.content.content.lower()) - - -class TestStreamingMessages: - def test_stream_simple_message(self, client: Agentex, agent_name: str): - stream = client.agents.send_message_stream( - agent_name=agent_name, - params=ParamsSendMessageRequest( - content=TextContentParam( - author="user", - content="Tell me a short joke.", - type="text", - ) - ), - ) - aggregated_content, chunks = collect_streaming_response(stream) - assert aggregated_content is not None - assert len(chunks) > 1, "No chunks received in streaming response." - - def test_stream_tool_calling(self, client: Agentex, agent_name: str): - stream = client.agents.send_message_stream( - agent_name=agent_name, - params=ParamsSendMessageRequest( - content=TextContentParam( - author="user", - content="What's the weather in New York?", - type="text", - ) - ), - ) - aggregated_content, chunks = collect_streaming_response(stream) - assert aggregated_content is not None - assert len(chunks) > 0, "No chunks received in streaming response." - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/00_sync/040_pydantic_ai/.dockerignore b/examples/tutorials/00_sync/040_pydantic_ai/.dockerignore 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 ef52c7c77..000000000 --- a/examples/tutorials/00_sync/040_pydantic_ai/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Sync Pydantic AI Agent - -A minimal **synchronous** Pydantic AI agent that drives the **unified harness -surface** (`UnifiedEmitter.yield_turn` + `PydanticAITurn`) on the sync -(HTTP-yield) channel. - -## Why this agent exists - -This agent is the sync coverage for the unified surface: it shows an agent -author wiring the sync channel through `UnifiedEmitter.yield_turn` and getting -automatic span derivation (tool spans nested under the per-turn span) for free, -exactly like the async/temporal channels. - -## How it wires the unified surface - -In `project/acp.py`: - -```python -emitter = UnifiedEmitter( - task_id=task_id, - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, -) -async with agent.run_stream_events(user_message) as stream: - turn = PydanticAITurn(stream, model=MODEL_NAME) # coalesce off: stream tool-call arg tokens - async for ev in emitter.yield_turn(turn): - yield ev -``` - -- `coalesce_tool_requests=False` (the default) preserves token-by-token - tool-call argument streaming on the sync channel. -- The `UnifiedEmitter` is constructed from the ACP/streaming context - (`task_id` + `trace_id` + `parent_span_id`) so tool spans nest under the - per-turn `AGENT_WORKFLOW` span automatically. - -## Files - -- `project/acp.py` โ€” sync ACP handler using `emitter.yield_turn(...)`. -- `project/agent.py` โ€” builds the `pydantic_ai.Agent` with one tool. -- `project/tools.py` โ€” `get_weather(city)` returning a constant. -- `tests/test_agent.py` โ€” live integration test (requires a running agent). - -## Tools - -- `get_weather(city: str) -> str`: returns a fixed "sunny and 72ยฐF" string so a - run deterministically exercises text + a tool call + a tool response. - -## Offline coverage - -Offline integration tests for the same wiring (pydantic-ai `TestModel` + fake -streaming/tracing, no network) live in the SDK repo under -`tests/lib/core/harness/` (the pydantic-ai sync suite). diff --git a/examples/tutorials/00_sync/040_pydantic_ai/manifest.yaml b/examples/tutorials/00_sync/040_pydantic_ai/manifest.yaml deleted file mode 100644 index 9563de39c..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 harness test agent using the unified emitter surface - - temporal: - enabled: false - - credentials: - - env_var_name: OPENAI_API_KEY - secret_name: openai-api-key - secret_key: api-key - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - - env_var_name: SGP_API_KEY - secret_name: sgp-api-key - secret_key: api-key - - env_var_name: SGP_ACCOUNT_ID - secret_name: sgp-account-id - secret_key: account-id - - env_var_name: SGP_CLIENT_BASE_URL - secret_name: sgp-client-base-url - secret_key: url - -deployment: - image: - repository: "" - tag: "latest" - - global: - agent: - name: "s040-pydantic-ai" - description: "A sync Pydantic AI harness test agent using the unified emitter surface" - replicaCount: 1 - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" diff --git a/examples/tutorials/00_sync/040_pydantic_ai/project/__init__.py b/examples/tutorials/00_sync/040_pydantic_ai/project/__init__.py 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 f23cd7960..000000000 --- a/examples/tutorials/00_sync/040_pydantic_ai/project/acp.py +++ /dev/null @@ -1,92 +0,0 @@ -"""ACP handler for the sync harness Pydantic AI test agent. - -This agent exercises the UNIFIED HARNESS SURFACE on the sync (HTTP-yield) -channel โ€” ``UnifiedEmitter.yield_turn(PydanticAITurn(...))`` โ€” rather than the -bare ``convert_pydantic_ai_to_agentex_events`` converter used by the -``040_pydantic_ai`` tutorial. The unified surface gives the sync channel the -same tracing (span derivation) the async/temporal channels get for free. - -Flow: -1. Open a per-turn AGENT_WORKFLOW span via ``adk.tracing.span``. -2. Construct a ``UnifiedEmitter`` from the ACP/streaming context (task_id + - trace_id + parent_span_id) so tool spans nest under the turn span. -3. Wrap ``agent.run_stream_events(...)`` in a ``PydanticAITurn`` and forward - events with ``emitter.yield_turn(turn)`` โ€” yielding each to the client. -""" - -from __future__ import annotations - -import os -from typing import AsyncGenerator - -from dotenv import load_dotenv - -load_dotenv() - -import agentex.lib.adk as adk -from project.agent import MODEL_NAME, create_agent -from agentex.lib.types.acp import SendMessageParams -from agentex.lib.core.harness import UnifiedEmitter -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.lib.sdk.fastacp.fastacp import FastACP -from agentex.types.task_message_update import TaskMessageUpdate -from agentex.types.task_message_content import TaskMessageContent -from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config - -logger = make_logger(__name__) - -add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SGP_API_KEY", ""), - sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - ) -) - -acp = FastACP.create(acp_type="sync") - -_agent = None - - -def get_agent(): - """Get or create the Pydantic AI agent instance.""" - global _agent - if _agent is None: - _agent = create_agent() - return _agent - - -@acp.on_message_send -async def handle_message_send( - params: SendMessageParams, -) -> TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]: - """Handle incoming messages, streaming events through the unified surface.""" - agent = get_agent() - task_id = params.task.id - - user_message = params.content.content - logger.info(f"Processing message for task {task_id}") - - async with adk.tracing.span( - trace_id=task_id, - task_id=task_id, - name="message", - input={"message": user_message}, - data={"__span_type__": "AGENT_WORKFLOW"}, - ) as turn_span: - # Construct the UnifiedEmitter from the ACP/streaming context so tracing - # is automatic: tool spans nest under this turn's span. - emitter = UnifiedEmitter( - task_id=task_id, - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - ) - - async with agent.run_stream_events(user_message) as stream: - # PydanticAITurn preserves token-by-token tool-call argument - # streaming (Start+Delta+Done) on the sync/HTTP channel. - turn = PydanticAITurn(stream, model=MODEL_NAME) - async for ev in emitter.yield_turn(turn): - yield ev diff --git a/examples/tutorials/00_sync/040_pydantic_ai/project/agent.py b/examples/tutorials/00_sync/040_pydantic_ai/project/agent.py deleted file mode 100644 index 72fd74173..000000000 --- a/examples/tutorials/00_sync/040_pydantic_ai/project/agent.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Pydantic AI agent definition for the sync harness test agent. - -The Agent is the boundary between this module and the API layer (acp.py). -Pydantic AI handles its own tool-call loop internally โ€” no graph required. -""" - -from __future__ import annotations - -from datetime import datetime - -from pydantic_ai import Agent - -from project.tools import get_weather - -__all__ = ["create_agent", "MODEL_NAME"] - -MODEL_NAME = "openai:gpt-4o-mini" -SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. - -Current date and time: {timestamp} - -Guidelines: -- Be concise and helpful -- Use tools when they would help answer the user's question -- If you're unsure, ask clarifying questions -- Always provide accurate information -""" - - -def create_agent() -> Agent: - """Build and return the Pydantic AI agent with tools registered.""" - agent = Agent( - MODEL_NAME, - system_prompt=SYSTEM_PROMPT.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")), - ) - - agent.tool_plain(get_weather) - - return agent diff --git a/examples/tutorials/00_sync/040_pydantic_ai/project/tools.py b/examples/tutorials/00_sync/040_pydantic_ai/project/tools.py deleted file mode 100644 index d649c75f1..000000000 --- a/examples/tutorials/00_sync/040_pydantic_ai/project/tools.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Tool definitions for the sync harness Pydantic AI agent. - -Pydantic AI tools are registered directly on the Agent via decorators -(see project.agent). This module hosts the bare function so it is easy to -unit-test in isolation. -""" - -from __future__ import annotations - - -def get_weather(city: str) -> str: - """Get the current weather for a city. - - Args: - city: The name of the city to get weather for. - - Returns: - A string describing the weather conditions. - """ - return f"The weather in {city} is sunny and 72ยฐF" diff --git a/examples/tutorials/00_sync/040_pydantic_ai/pyproject.toml b/examples/tutorials/00_sync/040_pydantic_ai/pyproject.toml deleted file mode 100644 index 748a9f3cb..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 harness test agent using the unified emitter surface" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "agentex-sdk", - "scale-gp", - "pydantic-ai-slim[openai]>=1.0,<2", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "httpx", - "black", - "isort", - "flake8", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] - -[tool.black] -line-length = 88 -target-version = ['py312'] - -[tool.isort] -profile = "black" -line_length = 88 diff --git a/examples/tutorials/00_sync/040_pydantic_ai/tests/test_agent.py b/examples/tutorials/00_sync/040_pydantic_ai/tests/test_agent.py deleted file mode 100644 index 4aad12a56..000000000 --- a/examples/tutorials/00_sync/040_pydantic_ai/tests/test_agent.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Live tests for the sync Pydantic AI agent. - -These tests require a running agent (server + deployed agent) and exercise the -unified-surface sync handler end-to-end over the wire. - -Offline coverage of the same wiring (TestModel + fake streaming/tracing) lives -in the SDK repo under ``tests/lib/core/harness/`` (the pydantic-ai sync suite). - -To run these tests: -1. Make sure the agent is running (via docker-compose or `agentex agents run`) -2. Set the AGENTEX_API_BASE_URL environment variable if not using default -3. Run: pytest test_agent.py -v - -Configuration: -- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) -- AGENT_NAME: Name of the agent to test (default: s040-pydantic-ai) -""" - -import os - -import pytest -from test_utils.sync import validate_text_in_string, collect_streaming_response - -from agentex import Agentex -from agentex.types import TextContentParam -from agentex.types.agent_rpc_params import ParamsSendMessageRequest - -AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") -AGENT_NAME = os.environ.get("AGENT_NAME", "s040-pydantic-ai") - - -@pytest.fixture -def client(): - """Create an AgentEx client instance for testing.""" - return Agentex(base_url=AGENTEX_API_BASE_URL) - - -@pytest.fixture -def agent_name(): - """Return the agent name for testing.""" - return AGENT_NAME - - -@pytest.fixture -def agent_id(client, agent_name): - """Retrieve the agent ID based on the agent name.""" - agents = client.agents.list() - for agent in agents: - if agent.name == agent_name: - return agent.id - raise ValueError(f"Agent with name {agent_name} not found.") - - -class TestNonStreamingMessages: - """Test non-streaming message sending with the unified-surface sync agent.""" - - def test_send_simple_message(self, client: Agentex, agent_name: str): - """Test sending a simple message and receiving a response.""" - response = client.agents.send_message( - agent_name=agent_name, - params=ParamsSendMessageRequest( - content=TextContentParam( - author="user", - content="Hello! What can you help me with?", - type="text", - ) - ), - ) - result = response.result - assert result is not None - assert len(result) >= 1 - - def test_tool_calling(self, client: Agentex, agent_name: str): - """Test that the agent can use tools (e.g., weather tool).""" - response = client.agents.send_message( - agent_name=agent_name, - params=ParamsSendMessageRequest( - content=TextContentParam( - author="user", - content="What's the weather in San Francisco?", - type="text", - ) - ), - ) - result = response.result - assert result is not None - assert len(result) >= 1 - - -class TestStreamingMessages: - """Test streaming message sending through the unified yield_turn path.""" - - def test_stream_simple_message(self, client: Agentex, agent_name: str): - """Test streaming a simple message response.""" - stream = client.agents.send_message_stream( - agent_name=agent_name, - params=ParamsSendMessageRequest( - content=TextContentParam( - author="user", - content="Tell me a short joke.", - type="text", - ) - ), - ) - - aggregated_content, chunks = collect_streaming_response(stream) - - assert aggregated_content is not None - assert len(chunks) > 1, "No chunks received in streaming response." - - def test_stream_tool_calling(self, client: Agentex, agent_name: str): - """Test streaming with tool calls through the unified surface. - - Exercises token-by-token tool-call argument streaming (coalesce off), - which the unified yield_turn path preserves on the sync channel. - """ - stream = client.agents.send_message_stream( - agent_name=agent_name, - params=ParamsSendMessageRequest( - content=TextContentParam( - author="user", - content="What's the weather in New York? Respond with the temperature.", - type="text", - ) - ), - ) - - aggregated_content, chunks = collect_streaming_response(stream) - - assert aggregated_content is not None - assert len(chunks) > 0, "No chunks received in streaming response." - # The weather tool always returns "72ยฐF", so the agent's reply should mention it. - validate_text_in_string("72", aggregated_content) - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/00_sync/050_openai_agents/.dockerignore b/examples/tutorials/00_sync/050_openai_agents/.dockerignore deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/00_sync/050_openai_agents/.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/Dockerfile b/examples/tutorials/00_sync/050_openai_agents/Dockerfile deleted file mode 100644 index c9ccd6f54..000000000 --- a/examples/tutorials/00_sync/050_openai_agents/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/pyproject.toml /app/050_openai_agents/pyproject.toml -COPY 00_sync/050_openai_agents/README.md /app/050_openai_agents/README.md - -WORKDIR /app/050_openai_agents - -# Copy the project code -COPY 00_sync/050_openai_agents/project /app/050_openai_agents/project - -# Copy the test files -COPY 00_sync/050_openai_agents/tests /app/050_openai_agents/tests - -# Copy shared test utilities -COPY test_utils /app/test_utils - -# Install the required Python packages with dev dependencies -RUN uv pip install --system .[dev] - -# Set environment variables -ENV PYTHONPATH=/app - -# Set test environment variables -ENV AGENT_NAME=s050-openai-agents - -# Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/00_sync/050_openai_agents/README.md b/examples/tutorials/00_sync/050_openai_agents/README.md deleted file mode 100644 index 98cec3f9a..000000000 --- a/examples/tutorials/00_sync/050_openai_agents/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Sync OpenAI Agents on the unified harness surface - -A sync (HTTP) Agentex agent that runs the OpenAI Agents SDK and delivers its -output through the **unified harness surface**. - -## What this demonstrates - -The OpenAI Agents SDK produces native streaming events. This tutorial wraps a -`Runner.run_streamed` result in an `OpenAITurn` โ€” the provider -> canonical -`StreamTaskMessage*` adapter โ€” and forwards the canonical stream to the frontend -via `UnifiedEmitter.yield_turn`. The same `OpenAITurn` flows unchanged through -`auto_send_turn` in the async (`10_async/00_base/120_openai_agents`) and temporal -(`10_async/10_temporal/120_openai_agents`) variants; only the delivery method differs. - -```python -result = Runner.run_streamed(starting_agent=agent, input=user_message) -turn = OpenAITurn(result=result, model="gpt-4o") -emitter = UnifiedEmitter(task_id=task_id, trace_id=task_id, parent_span_id=parent_span_id) -async for event in emitter.yield_turn(turn): - yield event -``` - -## Run it - -```bash -agentex agents run --manifest manifest.yaml -``` - -## Test it - -The offline test exercises the harness wiring without a server or API key: - -```bash -pytest tests/test_agent.py -v -``` diff --git a/examples/tutorials/00_sync/050_openai_agents/manifest.yaml b/examples/tutorials/00_sync/050_openai_agents/manifest.yaml deleted file mode 100644 index bdb47e8d8..000000000 --- a/examples/tutorials/00_sync/050_openai_agents/manifest.yaml +++ /dev/null @@ -1,58 +0,0 @@ -build: - context: - root: ../../ - include_paths: - - 00_sync/050_openai_agents - - test_utils - dockerfile: 00_sync/050_openai_agents/Dockerfile - dockerignore: 00_sync/050_openai_agents/.dockerignore - -local_development: - agent: - port: 8000 - host_address: host.docker.internal - paths: - acp: project/acp.py - -agent: - acp_type: sync - name: s050-openai-agents - description: A sync OpenAI Agents SDK agent on the unified harness surface - - temporal: - enabled: false - - credentials: - - env_var_name: OPENAI_API_KEY - secret_name: openai-api-key - secret_key: api-key - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - - env_var_name: SGP_API_KEY - secret_name: sgp-api-key - secret_key: api-key - - env_var_name: SGP_ACCOUNT_ID - secret_name: sgp-account-id - secret_key: account-id - - env_var_name: SGP_CLIENT_BASE_URL - secret_name: sgp-client-base-url - secret_key: url - -deployment: - image: - repository: "" - tag: "latest" - - global: - agent: - name: "s050-openai-agents" - description: "A sync OpenAI Agents SDK agent on the unified harness surface" - replicaCount: 1 - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" diff --git a/examples/tutorials/00_sync/050_openai_agents/project/__init__.py b/examples/tutorials/00_sync/050_openai_agents/project/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/00_sync/050_openai_agents/project/acp.py b/examples/tutorials/00_sync/050_openai_agents/project/acp.py deleted file mode 100644 index caaa0b132..000000000 --- a/examples/tutorials/00_sync/050_openai_agents/project/acp.py +++ /dev/null @@ -1,87 +0,0 @@ -"""ACP handler for the sync OpenAI Agents harness tutorial. - -This is the API layer. It runs the OpenAI Agents SDK via ``Runner.run_streamed``, -wraps the streamed run in an ``OpenAITurn`` (the provider -> canonical -``StreamTaskMessage*`` adapter), and forwards the canonical stream to the -Agentex frontend via ``UnifiedEmitter.yield_turn`` โ€” the same harness surface -used by the async and temporal variants of this tutorial. -""" - -from __future__ import annotations - -import os -from typing import AsyncGenerator - -from dotenv import load_dotenv - -load_dotenv() - -from agents import Runner - -from agentex.lib import adk -from project.agent import MODEL_NAME, create_agent -from agentex.lib.types.acp import SendMessageParams -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.lib.sdk.fastacp.fastacp import FastACP -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.types.task_message_update import TaskMessageUpdate -from agentex.types.task_message_content import TaskMessageContent -from agentex.lib.adk.providers._modules.openai_turn import OpenAITurn -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config - -logger = make_logger(__name__) - -# LiteLLM proxy auth: copy LITELLM_API_KEY to OPENAI_API_KEY for OpenAI client -# compatibility, so the same example works behind the Scale LiteLLM gateway. -_litellm_key = os.environ.get("LITELLM_API_KEY") -if _litellm_key and not os.environ.get("OPENAI_API_KEY"): - os.environ["OPENAI_API_KEY"] = _litellm_key - -add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SGP_API_KEY", ""), - sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - ) -) - -acp = FastACP.create(acp_type="sync") - -_agent = None - - -def get_agent(): - """Get or create the OpenAI Agents SDK agent instance.""" - global _agent - if _agent is None: - _agent = create_agent() - return _agent - - -@acp.on_message_send -async def handle_message_send( - params: SendMessageParams, -) -> TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]: - """Handle incoming messages, streaming tokens and tool calls via the harness.""" - agent = get_agent() - task_id = params.task.id - user_message = params.content.content - logger.info(f"Processing message for task {task_id}") - - async with adk.tracing.span( - trace_id=task_id, - task_id=task_id, - name="message", - input={"message": user_message}, - data={"__span_type__": "AGENT_WORKFLOW"}, - ) as turn_span: - result = Runner.run_streamed(starting_agent=agent, input=user_message) - turn = OpenAITurn(result=result, model=MODEL_NAME) - emitter = UnifiedEmitter( - task_id=task_id, - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - ) - async for event in emitter.yield_turn(turn): - yield event diff --git a/examples/tutorials/00_sync/050_openai_agents/project/agent.py b/examples/tutorials/00_sync/050_openai_agents/project/agent.py deleted file mode 100644 index 3611012fe..000000000 --- a/examples/tutorials/00_sync/050_openai_agents/project/agent.py +++ /dev/null @@ -1,47 +0,0 @@ -"""OpenAI Agents SDK agent definition for the harness tutorial. - -The agent is the boundary between this module and the API layer (acp.py). -The OpenAI Agents SDK runs its own tool-call loop internally; acp.py wraps a -``Runner.run_streamed`` result with ``OpenAITurn`` so it flows through the -unified harness surface. -""" - -from __future__ import annotations - -from datetime import datetime - -from agents import Agent, function_tool, set_tracing_disabled - -from project.tools import get_weather - -# Disable the openai-agents SDK's native tracer so it doesn't ship traces to -# api.openai.com (the key may be a gateway/proxy key). Agentex tracing still -# runs via the harness + tracing manager configured in acp.py. -set_tracing_disabled(True) - -MODEL_NAME = "gpt-4o" -INSTRUCTIONS = """You are a helpful AI assistant with access to tools. - -Current date and time: {timestamp} - -Guidelines: -- Be concise and helpful -- Use the weather tool when the user asks about the weather -- Always report the real tool output back to the user -""" - - -@function_tool -def weather(city: str) -> str: - """Get the current weather for a city.""" - return get_weather(city) - - -def create_agent() -> Agent: - """Build and return the OpenAI Agents SDK agent with the weather tool.""" - return Agent( - name="Harness OpenAI Assistant", - model=MODEL_NAME, - instructions=INSTRUCTIONS.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")), - tools=[weather], - ) diff --git a/examples/tutorials/00_sync/050_openai_agents/project/tools.py b/examples/tutorials/00_sync/050_openai_agents/project/tools.py deleted file mode 100644 index b03aa7c31..000000000 --- a/examples/tutorials/00_sync/050_openai_agents/project/tools.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Tool definitions for the OpenAI Agents harness tutorial. - -The bare function lives here so it's easy to unit-test; it's wrapped as an -OpenAI Agents SDK ``function_tool`` in ``project.agent``. -""" - -from __future__ import annotations - - -def get_weather(city: str) -> str: - """Get the current weather for a city. - - Args: - city: The name of the city to get weather for. - - Returns: - A string describing the weather conditions. - """ - return f"The weather in {city} is sunny and 72ยฐF" diff --git a/examples/tutorials/00_sync/050_openai_agents/pyproject.toml b/examples/tutorials/00_sync/050_openai_agents/pyproject.toml deleted file mode 100644 index 48d2481dd..000000000 --- a/examples/tutorials/00_sync/050_openai_agents/pyproject.toml +++ /dev/null @@ -1,36 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "s050-openai-agents" -version = "0.1.0" -description = "A sync OpenAI Agents SDK agent on the unified harness surface" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "agentex-sdk", - "scale-gp", - "openai-agents", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "httpx", - "black", - "isort", - "flake8", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] - -[tool.black] -line-length = 88 -target-version = ['py312'] - -[tool.isort] -profile = "black" -line_length = 88 diff --git a/examples/tutorials/00_sync/050_openai_agents/tests/test_agent.py b/examples/tutorials/00_sync/050_openai_agents/tests/test_agent.py deleted file mode 100644 index 960b232b7..000000000 --- a/examples/tutorials/00_sync/050_openai_agents/tests/test_agent.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Offline test for the sync OpenAI Agents harness tutorial. - -This test does NOT require a running Agentex server or an OpenAI API key. It -verifies the harness wiring this tutorial demonstrates: an ``OpenAITurn`` built -from an injected canonical ``StreamTaskMessage*`` stream, forwarded through -``UnifiedEmitter.yield_turn`` (the sync HTTP ACP delivery path), passes the -events through unchanged. - -To run: ``pytest tests/test_agent.py -v`` -""" - -from __future__ import annotations - -import pytest - -from agentex.types.text_content import TextContent -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.types.task_message_delta import TextDelta -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.lib.adk.providers._modules.openai_turn import OpenAITurn - - -async def _canonical_stream(events): - for e in events: - yield e - - -@pytest.mark.asyncio -async def test_yield_turn_forwards_canonical_stream(): - events = [ - StreamTaskMessageStart(type="start", index=0, content=TextContent(type="text", author="agent", content="")), - StreamTaskMessageDelta(type="delta", index=0, delta=TextDelta(type="text", text_delta="Hi")), - StreamTaskMessageDone(type="done", index=0), - ] - turn = OpenAITurn(stream=_canonical_stream(events), model="gpt-4o") - # trace_id=None disables tracing, so no Agentex server is needed. - emitter = UnifiedEmitter(task_id="task-1", trace_id=None, parent_span_id=None) - - out = [e async for e in emitter.yield_turn(turn)] - assert out == events - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/00_sync/060_claude_code/.dockerignore b/examples/tutorials/00_sync/060_claude_code/.dockerignore deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/00_sync/060_claude_code/.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/060_claude_code/Dockerfile b/examples/tutorials/00_sync/060_claude_code/Dockerfile deleted file mode 100644 index ec22d7e0b..000000000 --- a/examples/tutorials/00_sync/060_claude_code/Dockerfile +++ /dev/null @@ -1,46 +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 including Node.js (required by the claude CLI) -RUN apt-get update && apt-get install -y \ - htop \ - vim \ - curl \ - tar \ - python3-dev \ - postgresql-client \ - build-essential \ - libpq-dev \ - gcc \ - cmake \ - netcat-openbsd \ - nodejs \ - npm \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -RUN uv pip install --system --upgrade pip setuptools wheel - -# Install the claude CLI (requires Node.js) -# NOTE: live runs require ANTHROPIC_API_KEY in the environment. -RUN npm install -g @anthropic-ai/claude-code || true - -ENV UV_HTTP_TIMEOUT=1000 - -COPY 00_sync/060_claude_code/pyproject.toml /app/060_claude_code/pyproject.toml -COPY 00_sync/060_claude_code/README.md /app/060_claude_code/README.md - -WORKDIR /app/060_claude_code - -COPY 00_sync/060_claude_code/project /app/060_claude_code/project -COPY 00_sync/060_claude_code/tests /app/060_claude_code/tests -COPY test_utils /app/test_utils - -RUN uv pip install --system .[dev] - -ENV PYTHONPATH=/app - -ENV AGENT_NAME=s060-claude-code - -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/00_sync/060_claude_code/README.md b/examples/tutorials/00_sync/060_claude_code/README.md deleted file mode 100644 index e9c724732..000000000 --- a/examples/tutorials/00_sync/060_claude_code/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# Tutorial 060: Sync Claude Code Agent - -This tutorial demonstrates how to build a **synchronous** agent that spawns the -Claude Code CLI as a local subprocess and streams its output through the Agentex -unified harness surface via ``ClaudeCodeTurn`` and ``UnifiedEmitter``. - -## Key Concepts - -### ClaudeCodeTurn + UnifiedEmitter - -``ClaudeCodeTurn`` wraps ``convert_claude_code_to_agentex_events``, which -parses the newline-delimited JSON envelopes emitted by -``claude -p --output-format stream-json``. It implements the ``HarnessTurn`` -protocol: an ``events`` async iterator of canonical ``StreamTaskMessage*`` -objects and a ``usage()`` method (populated once the stream is exhausted). - -``UnifiedEmitter.yield_turn(turn)`` is the sync delivery path: it forwards -events as HTTP yield chunks while tracing as a side effect. - -### Local subprocess spawn - -The ``_spawn_claude`` function in ``project/acp.py`` uses -``asyncio.create_subprocess_exec`` to run: - -``` -claude -p --output-format stream-json --verbose -``` - -The prompt is written to stdin. Stdout is read line by line and fed into -``ClaudeCodeTurn``. This is purely local -- no Scale sandbox is involved. - -Production isolation (Scale sandbox, secret injection, MCP configuration) -is the golden agent's concern at -``teams/sgp/agents/golden_agent/project/harness/providers/claude.py``. - -### Injectable spawn seam - -``_spawn_claude`` is a top-level async generator in ``project/acp.py``. -Tests monkeypatch it to inject pre-recorded stream-json lines instead of -spawning the real process, so offline unit tests run without the CLI. - -## Files - -| File | Description | -|------|-------------| -| ``project/acp.py`` | ACP server, ``_spawn_claude`` seam, and message handler | -| ``tests/test_agent.py`` | Live integration tests (needs CLI + API key) | -| ``tests/test_agent_offline.py`` | Offline unit tests with injected fake subprocess | -| ``manifest.yaml`` | Agent configuration | - -## Running Locally (live) - -Requires the ``claude`` CLI installed and ``ANTHROPIC_API_KEY`` set: - -```bash -npm install -g @anthropic-ai/claude-code -export ANTHROPIC_API_KEY=sk-ant-... -agentex agents run -``` - -## Running Offline Tests - -No CLI or API key needed: - -```bash -uv run pytest tests/test_agent_offline.py -v -``` - -## Notes - -- Production isolation (sandbox, secrets, MCP) is the golden agent's concern. - This tutorial runs the CLI directly to keep the code as simple as possible. -- Multi-turn session resumption (``claude -r ``) is out of scope - for this tutorial. See the golden agent for that pattern. -- The ``--verbose`` flag is included to match the golden agent's invocation; - it causes the CLI to emit ``stream_event`` triples for incremental streaming. diff --git a/examples/tutorials/00_sync/060_claude_code/manifest.yaml b/examples/tutorials/00_sync/060_claude_code/manifest.yaml deleted file mode 100644 index 56b9fd9e4..000000000 --- a/examples/tutorials/00_sync/060_claude_code/manifest.yaml +++ /dev/null @@ -1,55 +0,0 @@ -build: - context: - root: ../../ - include_paths: - - 00_sync/060_claude_code - - test_utils - dockerfile: 00_sync/060_claude_code/Dockerfile - dockerignore: 00_sync/060_claude_code/.dockerignore - -local_development: - agent: - port: 8000 - host_address: host.docker.internal - paths: - acp: project/acp.py - -agent: - acp_type: sync - name: s060-claude-code - description: A sync Claude Code agent streaming the unified harness surface via a local CLI subprocess - - temporal: - enabled: false - - credentials: - - env_var_name: ANTHROPIC_API_KEY - secret_name: anthropic-api-key - secret_key: api-key - - env_var_name: SGP_API_KEY - secret_name: sgp-api-key - secret_key: api-key - - env_var_name: SGP_ACCOUNT_ID - secret_name: sgp-account-id - secret_key: account-id - - env_var_name: SGP_CLIENT_BASE_URL - secret_name: sgp-client-base-url - secret_key: url - -deployment: - image: - repository: "" - tag: "latest" - - global: - agent: - name: "s060-claude-code" - description: "A sync Claude Code agent streaming via local CLI subprocess" - replicaCount: 1 - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" diff --git a/examples/tutorials/00_sync/060_claude_code/project/__init__.py b/examples/tutorials/00_sync/060_claude_code/project/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/00_sync/060_claude_code/project/acp.py b/examples/tutorials/00_sync/060_claude_code/project/acp.py deleted file mode 100644 index aad53801a..000000000 --- a/examples/tutorials/00_sync/060_claude_code/project/acp.py +++ /dev/null @@ -1,137 +0,0 @@ -"""ACP handler for the sync Claude Code tutorial. - -Spawns ``claude -p --output-format stream-json --verbose`` as a LOCAL -asyncio subprocess (no Scale sandbox -- that is the golden agent's -production concern). Stdout lines are fed into ``ClaudeCodeTurn``, which -wraps ``convert_claude_code_to_agentex_events``. Events are delivered via -``UnifiedEmitter.yield_turn``, the sync HTTP yield path. - -Live runs require the ``claude`` CLI to be installed and an -ANTHROPIC_API_KEY (or equivalent credential) to be in the environment. -For offline testing, see ``tests/test_agent_offline.py``, which injects a -fake subprocess. -""" - -from __future__ import annotations - -import os -import asyncio -from typing import AsyncIterator, AsyncGenerator - -from dotenv import load_dotenv - -load_dotenv() - -import agentex.lib.adk as adk -from agentex.lib.adk import ClaudeCodeTurn -from agentex.lib.types.acp import SendMessageParams -from agentex.lib.core.harness import UnifiedEmitter -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.lib.sdk.fastacp.fastacp import FastACP -from agentex.types.task_message_update import TaskMessageUpdate -from agentex.types.task_message_content import TaskMessageContent -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config - -logger = make_logger(__name__) - -add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SGP_API_KEY", ""), - sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - ) -) - -acp = FastACP.create(acp_type="sync") - - -async def _spawn_claude(prompt: str) -> AsyncIterator[str]: - """Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines. - - This is a seam: tests replace it with a fake async iterator of - pre-recorded lines so no real CLI invocation is needed offline. - """ - proc = await asyncio.create_subprocess_exec( - "claude", - "-p", - "--output-format", - "stream-json", - "--verbose", - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - assert proc.stdout is not None - assert proc.stdin is not None - - proc.stdin.write(prompt.encode()) - proc.stdin.close() - - # Drain stderr concurrently. With --verbose, Claude Code can write enough to - # stderr to fill the OS pipe buffer; if we only read stdout, the CLI blocks - # on its stderr write while we block reading stdout โ€” a deadlock. A - # background task keeps stderr flowing so stdout never stalls. - async def _drain_stderr() -> None: - assert proc.stderr is not None - async for _ in proc.stderr: - pass - - stderr_task = asyncio.create_task(_drain_stderr()) - - try: - buffer = "" - async for chunk in proc.stdout: - buffer += chunk.decode("utf-8", errors="replace") - while "\n" in buffer: - line, buffer = buffer.split("\n", 1) - line = line.strip() - if line: - yield line - - if buffer.strip(): - yield buffer.strip() - - await proc.wait() - finally: - # Release the subprocess and stderr drain task even if the consumer - # abandons the generator early (task cancellation / client disconnect): - # cancel the drain task and terminate+reap the process if it is still - # running, so neither is leaked. - stderr_task.cancel() - try: - await stderr_task - except asyncio.CancelledError: - pass - if proc.returncode is None: - try: - proc.terminate() - except ProcessLookupError: - pass - await proc.wait() - - -@acp.on_message_send -async def handle_message_send( - params: SendMessageParams, -) -> TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]: - """Handle an incoming message: run Claude Code locally and stream events.""" - task_id = params.task.id - prompt = params.content.content - logger.info("Processing message for task %s", task_id) - - async with adk.tracing.span( - trace_id=task_id, - task_id=task_id, - name="message", - input={"message": prompt}, - data={"__span_type__": "AGENT_WORKFLOW"}, - ) as turn_span: - emitter = UnifiedEmitter( - task_id=task_id, - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - ) - turn = ClaudeCodeTurn(_spawn_claude(prompt)) - async for event in emitter.yield_turn(turn): - yield event diff --git a/examples/tutorials/00_sync/060_claude_code/pyproject.toml b/examples/tutorials/00_sync/060_claude_code/pyproject.toml deleted file mode 100644 index e5c1c4ea6..000000000 --- a/examples/tutorials/00_sync/060_claude_code/pyproject.toml +++ /dev/null @@ -1,25 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "s060-claude-code" -version = "0.1.0" -description = "A sync Claude Code agent streaming the unified harness surface via a local CLI subprocess" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "agentex-sdk", - "scale-gp", - "python-dotenv>=1.0,<2", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "httpx", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] diff --git a/examples/tutorials/00_sync/060_claude_code/tests/test_agent.py b/examples/tutorials/00_sync/060_claude_code/tests/test_agent.py deleted file mode 100644 index 954a520f3..000000000 --- a/examples/tutorials/00_sync/060_claude_code/tests/test_agent.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Tests for the sync Claude Code tutorial agent. - -LIVE tests (``TestClaudeCodeLive``): - - Require the ``claude`` CLI on PATH and ``ANTHROPIC_API_KEY`` set. - - Run the full agent end-to-end against a live Agentex server. - - Skipped automatically when ``CLAUDE_LIVE_TESTS`` is not set to ``1``. - -OFFLINE unit tests (``TestClaudeCodeOffline``): - - Inject a fake async iterator of pre-recorded stream-json lines. - - Assert the ``ClaudeCodeTurn`` + ``UnifiedEmitter`` pipeline yields events, - populates usage, and satisfies the ``HarnessTurn`` protocol. - - Always run -- no CLI or API key needed. -""" - -from __future__ import annotations - -import os -import json -from typing import AsyncIterator - -import pytest - -# --------------------------------------------------------------------------- -# Recorded stream-json fixtures -# --------------------------------------------------------------------------- - -_TEXT_ONLY_LINES: list[str] = [ - json.dumps({"type": "system", "subtype": "init", "session_id": "sess-offline-1"}), - json.dumps( - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "Hello from Claude Code!"}]}, - } - ), - json.dumps( - { - "type": "result", - "usage": {"input_tokens": 10, "output_tokens": 5}, - "cost_usd": 0.0001, - "duration_ms": 250, - "num_turns": 1, - } - ), -] - - -async def _fake_lines(lines: list[str]) -> AsyncIterator[str]: - """Async iterator of pre-recorded stream-json lines (no subprocess).""" - for line in lines: - yield line - - -# --------------------------------------------------------------------------- -# Offline tests (always run -- no CLI or API key needed) -# --------------------------------------------------------------------------- - - -class TestClaudeCodeOffline: - """Unit tests that run without a real claude CLI or network.""" - - @pytest.mark.asyncio - async def test_yields_stream_events(self): - """ClaudeCodeTurn drives UnifiedEmitter and yields StreamTaskMessage* events.""" - from agentex.lib.adk import ClaudeCodeTurn - from agentex.lib.core.harness import UnifiedEmitter - from agentex.types.task_message_update import StreamTaskMessageStart - - turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) - emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) - - events = [e async for e in emitter.yield_turn(turn)] - assert len(events) > 0, "No events yielded" - assert any(isinstance(e, StreamTaskMessageStart) for e in events) - - @pytest.mark.asyncio - async def test_stream_task_message_done_present(self): - """StreamTaskMessageDone must appear after stream exhaustion.""" - from agentex.lib.adk import ClaudeCodeTurn - from agentex.lib.core.harness import UnifiedEmitter - from agentex.types.task_message_update import StreamTaskMessageDone - - turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) - emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) - - events = [e async for e in emitter.yield_turn(turn)] - assert any(isinstance(e, StreamTaskMessageDone) for e in events), ( - "Expected at least one StreamTaskMessageDone event" - ) - - @pytest.mark.asyncio - async def test_usage_populated_after_stream_exhausted(self): - """ClaudeCodeTurn.usage() returns correct tokens after stream is exhausted.""" - from agentex.lib.adk import ClaudeCodeTurn - - turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) - _ = [e async for e in turn.events] - usage = turn.usage() - assert usage.input_tokens == 10 - assert usage.output_tokens == 5 - assert usage.num_llm_calls == 1 - - @pytest.mark.asyncio - async def test_protocol_compliance(self): - """ClaudeCodeTurn satisfies the HarnessTurn protocol.""" - from agentex.lib.adk import ClaudeCodeTurn - - turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) - assert hasattr(turn, "events"), "ClaudeCodeTurn missing .events" - assert hasattr(turn, "usage"), "ClaudeCodeTurn missing .usage()" - - -# --------------------------------------------------------------------------- -# Live tests (skipped unless CLAUDE_LIVE_TESTS=1) -# --------------------------------------------------------------------------- - -pytestmark_live = pytest.mark.skipif( - not os.environ.get("CLAUDE_LIVE_TESTS"), - reason="Set CLAUDE_LIVE_TESTS=1 and ensure the `claude` CLI + ANTHROPIC_API_KEY are available", -) - -AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") -AGENT_NAME = os.environ.get("AGENT_NAME", "s060-claude-code") - - -@pytestmark_live -class TestClaudeCodeLive: - """Live streaming tests -- needs the claude CLI + ANTHROPIC_API_KEY.""" - - @pytest.fixture - def client(self): - from agentex import Agentex - - return Agentex(base_url=AGENTEX_API_BASE_URL) - - @pytest.fixture - def agent_name(self): - return AGENT_NAME - - def test_stream_simple_message(self, client, agent_name: str): - """Stream a simple prompt through the local Claude Code subprocess.""" - from test_utils.sync import collect_streaming_response - - from agentex.types import TextContentParam - from agentex.types.agent_rpc_params import ParamsSendMessageRequest - - stream = client.agents.send_message_stream( - agent_name=agent_name, - params=ParamsSendMessageRequest( - content=TextContentParam( - author="user", - content="Reply with exactly three words: hello from claude", - type="text", - ) - ), - ) - aggregated_content, chunks = collect_streaming_response(stream) - assert aggregated_content is not None - assert len(chunks) >= 1 - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/00_sync/060_claude_code/tests/test_agent_offline.py b/examples/tutorials/00_sync/060_claude_code/tests/test_agent_offline.py deleted file mode 100644 index 23ac52a57..000000000 --- a/examples/tutorials/00_sync/060_claude_code/tests/test_agent_offline.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Offline unit tests for the sync Claude Code tutorial agent. - -These tests do NOT require the ``claude`` CLI or an ANTHROPIC_API_KEY. -They inject a fake async iterator of pre-recorded stream-json lines in -place of the real subprocess spawn, and a fake streaming backend in place -of the real Redis/AGP layer, then assert that the handler correctly drives -the unified surface (``UnifiedEmitter.yield_turn``). - -The injection seam is the ``_spawn_claude`` function in ``project/acp.py``. -Tests monkeypatch it with a coroutine that returns a pre-recorded async -iterator, so the handler code runs in full without any subprocess. -""" - -from __future__ import annotations - -import json -from typing import AsyncIterator - -import pytest - -from agentex.lib.adk import ClaudeCodeTurn -from agentex.lib.core.harness import UnifiedEmitter -from agentex.types.task_message_update import ( - StreamTaskMessageStart, -) - -# --------------------------------------------------------------------------- -# Recorded stream-json fixtures -# --------------------------------------------------------------------------- - -_TEXT_ONLY_LINES: list[str] = [ - json.dumps({"type": "system", "subtype": "init", "session_id": "sess-1"}), - json.dumps( - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "Hello from Claude Code!"}]}, - } - ), - json.dumps( - { - "type": "result", - "usage": {"input_tokens": 10, "output_tokens": 5}, - "cost_usd": 0.0001, - "duration_ms": 250, - "num_turns": 1, - } - ), -] - -_TOOL_CALL_LINES: list[str] = [ - json.dumps({"type": "system", "subtype": "init", "session_id": "sess-2"}), - json.dumps( - { - "type": "assistant", - "message": { - "content": [ - { - "type": "tool_use", - "id": "tool_abc", - "name": "Bash", - "input": {"command": "echo hello"}, - } - ] - }, - } - ), - json.dumps( - { - "type": "user", - "message": { - "content": [ - { - "type": "tool_result", - "tool_use_id": "tool_abc", - "content": "hello\n", - "is_error": False, - } - ] - }, - } - ), - json.dumps( - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "Done."}]}, - } - ), - json.dumps( - { - "type": "result", - "usage": {"input_tokens": 20, "output_tokens": 8}, - "cost_usd": 0.0002, - "duration_ms": 400, - "num_turns": 1, - } - ), -] - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -async def _fake_lines(lines: list[str]) -> AsyncIterator[str]: - for line in lines: - yield line - - -async def _collect_yield_turn(lines: list[str]) -> list: - """Run a ClaudeCodeTurn through UnifiedEmitter.yield_turn and collect events.""" - turn = ClaudeCodeTurn(_fake_lines(lines)) - emitter = UnifiedEmitter(task_id="t1", trace_id=None, parent_span_id=None) - return [e async for e in emitter.yield_turn(turn)] - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_text_only_produces_start_and_done(): - events = await _collect_yield_turn(_TEXT_ONLY_LINES) - types = [type(e).__name__ for e in events] - assert "StreamTaskMessageStart" in types - assert "StreamTaskMessageDone" in types - - -@pytest.mark.asyncio -async def test_text_only_content(): - events = await _collect_yield_turn(_TEXT_ONLY_LINES) - starts = [e for e in events if isinstance(e, StreamTaskMessageStart)] - assert len(starts) == 1 - assert starts[0].content.type == "text" - - -@pytest.mark.asyncio -async def test_usage_is_populated_after_stream(): - turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) - _ = [e async for e in turn.events] - usage = turn.usage() - assert usage.input_tokens == 10 - assert usage.output_tokens == 5 - assert usage.cost_usd == pytest.approx(0.0001, rel=1e-4) - assert usage.num_llm_calls == 1 - - -@pytest.mark.asyncio -async def test_tool_call_produces_tool_request_and_response(): - events = await _collect_yield_turn(_TOOL_CALL_LINES) - content_types = { - getattr(e, "content", None) and getattr(e.content, "type", None) for e in events if hasattr(e, "content") - } - assert "tool_request" in content_types - assert "tool_response" in content_types - - -@pytest.mark.asyncio -async def test_tool_call_has_one_text_block(): - """The tool_use block is not text; only 'Done.' is the text block.""" - events = await _collect_yield_turn(_TOOL_CALL_LINES) - text_starts = [ - e for e in events if isinstance(e, StreamTaskMessageStart) and getattr(e.content, "type", None) == "text" - ] - assert len(text_starts) == 1 - - -@pytest.mark.asyncio -async def test_empty_lines_are_skipped(): - """Inserting blank lines in the stream must not crash the parser.""" - lines_with_blanks = ["", " "] + _TEXT_ONLY_LINES + [""] - events = await _collect_yield_turn(lines_with_blanks) - assert any(isinstance(e, StreamTaskMessageStart) for e in events) - - -@pytest.mark.asyncio -async def test_spawn_seam_concept(): - """Demonstrate the injectable spawn seam pattern used in project/acp.py. - - The ``_spawn_claude`` function in ``project/acp.py`` is a top-level async - generator. Production code calls it like:: - - turn = ClaudeCodeTurn(_spawn_claude(prompt)) - - In tests, a replacement function is injected (e.g. via monkeypatch) to - return pre-recorded lines. This test proves the pattern works end-to-end - without importing the full ACP module (which has module-level env-var - checks that only pass in a running agent environment). - """ - recorded_lines = _TEXT_ONLY_LINES - - async def _fake_spawn(prompt: str) -> AsyncIterator[str]: # noqa: ARG001 - """Drop-in replacement for _spawn_claude.""" - for line in recorded_lines: - yield line - - called_with: list[str] = [] - - async def _wrapped_spawn(prompt: str) -> AsyncIterator[str]: - called_with.append(prompt) - async for line in _fake_spawn(prompt): - yield line - - turn = ClaudeCodeTurn(_wrapped_spawn("test prompt")) - emitter = UnifiedEmitter(task_id="t2", trace_id=None, parent_span_id=None) - events = [e async for e in emitter.yield_turn(turn)] - - assert called_with == ["test prompt"] - assert any(isinstance(e, StreamTaskMessageStart) for e in events) diff --git a/examples/tutorials/00_sync/070_codex/.dockerignore b/examples/tutorials/00_sync/070_codex/.dockerignore deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/00_sync/070_codex/.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/070_codex/Dockerfile b/examples/tutorials/00_sync/070_codex/Dockerfile deleted file mode 100644 index fb500b221..000000000 --- a/examples/tutorials/00_sync/070_codex/Dockerfile +++ /dev/null @@ -1,56 +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 the codex CLI: the agent spawns `codex exec --json`, so the binary -# must be present on PATH in the image. -RUN npm install -g @openai/codex - -RUN uv pip install --system --upgrade pip setuptools wheel - -ENV UV_HTTP_TIMEOUT=1000 - -# Copy pyproject.toml and README.md to install dependencies -COPY 00_sync/070_codex/pyproject.toml /app/070_codex/pyproject.toml -COPY 00_sync/070_codex/README.md /app/070_codex/README.md - -WORKDIR /app/070_codex - -# Copy the project code -COPY 00_sync/070_codex/project /app/070_codex/project - -# Copy the test files -COPY 00_sync/070_codex/tests /app/070_codex/tests - -# Copy shared test utilities -COPY test_utils /app/test_utils - -# Install the required Python packages with dev dependencies -RUN uv pip install --system .[dev] - -# Set environment variables -ENV PYTHONPATH=/app - -# Set test environment variables -ENV AGENT_NAME=s070-codex - -# Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/00_sync/070_codex/README.md b/examples/tutorials/00_sync/070_codex/README.md deleted file mode 100644 index 3abb2766f..000000000 --- a/examples/tutorials/00_sync/070_codex/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# 070_codex (sync) - -Tutorial agent demonstrating the `convert_codex_to_agentex_events` tap, -`CodexTurn`, and `UnifiedEmitter` for a **sync** (HTTP-yield) ACP agent. - -## What this tutorial shows - -- Spawning `codex exec --json` as a **local asyncio subprocess** (no Scale sandbox). -- Wrapping the stdout line stream in a `CodexTurn`. -- Delivering every canonical `StreamTaskMessage*` event to the HTTP caller via - `UnifiedEmitter.yield_turn` (tracing as a side-effect). - -> **Production isolation note:** A tutorial agent runs the Codex CLI locally. -> Production-grade isolation (Scale sandbox, secret injection, MCP configuration) -> is handled by the golden agent at -> `teams/sgp/agents/golden_agent/project/harness/providers/codex.py`. - -## Live runs - -Live runs require: -1. The `codex` CLI on PATH: `npm install -g @openai/codex` -2. `OPENAI_API_KEY` set in the environment. - -## Running offline unit tests - -The offline tests inject a fake subprocess and never invoke the real CLI: - -```bash -cd /path/to/scale-agentex-python -uv run --all-packages --all-extras pytest examples/tutorials/00_sync/070_codex/tests/test_agent.py -q -``` - -## Running live integration tests - -```bash -export CODEX_LIVE_TESTS=1 -export OPENAI_API_KEY=sk-... -# Start the agent server first, then: -pytest tests/test_agent.py -v -``` diff --git a/examples/tutorials/00_sync/070_codex/conftest.py b/examples/tutorials/00_sync/070_codex/conftest.py deleted file mode 100644 index bdd78994b..000000000 --- a/examples/tutorials/00_sync/070_codex/conftest.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Add the agent's project root to sys.path so ``import project`` works. - -Also sets minimal environment variables so the FastACP and tracing modules -can be imported without a running agent server. -""" - -import os -import sys - -sys.path.insert(0, os.path.dirname(__file__)) - -os.environ.setdefault("ACP_URL", "http://localhost:8000") diff --git a/examples/tutorials/00_sync/070_codex/manifest.yaml b/examples/tutorials/00_sync/070_codex/manifest.yaml deleted file mode 100644 index 87dad2847..000000000 --- a/examples/tutorials/00_sync/070_codex/manifest.yaml +++ /dev/null @@ -1,58 +0,0 @@ -build: - context: - root: ../../ - include_paths: - - 00_sync/070_codex - - test_utils - dockerfile: 00_sync/070_codex/Dockerfile - dockerignore: 00_sync/070_codex/.dockerignore - -local_development: - agent: - port: 8000 - host_address: host.docker.internal - paths: - acp: project/acp.py - -agent: - acp_type: sync - name: s070-codex - description: Sync tutorial agent driving the unified harness surface via local codex CLI subprocess - - temporal: - enabled: false - - credentials: - - env_var_name: OPENAI_API_KEY - secret_name: openai-api-key - secret_key: api-key - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - - env_var_name: SGP_API_KEY - secret_name: sgp-api-key - secret_key: api-key - - env_var_name: SGP_ACCOUNT_ID - secret_name: sgp-account-id - secret_key: account-id - - env_var_name: SGP_CLIENT_BASE_URL - secret_name: sgp-client-base-url - secret_key: url - -deployment: - image: - repository: "" - tag: "latest" - - global: - agent: - name: "s070-codex" - description: "Sync tutorial agent driving the unified harness surface via local codex CLI subprocess" - replicaCount: 1 - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" diff --git a/examples/tutorials/00_sync/070_codex/project/__init__.py b/examples/tutorials/00_sync/070_codex/project/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/00_sync/070_codex/project/acp.py b/examples/tutorials/00_sync/070_codex/project/acp.py deleted file mode 100644 index bcb5e10df..000000000 --- a/examples/tutorials/00_sync/070_codex/project/acp.py +++ /dev/null @@ -1,175 +0,0 @@ -"""Sync ACP handler for the Codex CLI harness tutorial. - -Demonstrates the ``convert_codex_to_agentex_events`` tap + ``CodexTurn`` + -``UnifiedEmitter`` for a sync (HTTP-yield) ACP agent. - -The handler: -1. Spawns ``codex exec --json`` as a LOCAL asyncio subprocess (no sandbox). - This is correct for tutorials and local development; production isolation - is handled by the golden agent's Scale sandbox at - ``teams/sgp/agents/golden_agent/project/harness/providers/codex.py``. -2. Wraps the stdout line stream in a ``CodexTurn``. -3. Delivers every canonical ``StreamTaskMessage*`` event via - ``UnifiedEmitter.yield_turn``, which traces + yields each event back to - the HTTP caller in one pass. - -Live runs require: -- ``codex`` CLI on PATH (``npm install -g @openai/codex``) -- ``OPENAI_API_KEY`` set in the environment -""" - -from __future__ import annotations - -import os -import time -import codecs -import asyncio -from typing import AsyncGenerator -from collections.abc import AsyncIterator - -from dotenv import load_dotenv - -load_dotenv() - -import agentex.lib.adk as adk -from agentex.lib.adk import CodexTurn -from agentex.lib.types.acp import SendMessageParams -from agentex.lib.core.harness import UnifiedEmitter -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.lib.sdk.fastacp.fastacp import FastACP -from agentex.types.task_message_update import TaskMessageUpdate -from agentex.types.task_message_content import TaskMessageContent -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config - -logger = make_logger(__name__) - -add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SGP_API_KEY", ""), - sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - ) -) - -acp = FastACP.create(acp_type="sync") - -MODEL = os.environ.get("CODEX_MODEL", "o4-mini") - - -async def _spawn_codex(model: str) -> asyncio.subprocess.Process: - """Spawn ``codex exec --json`` locally and return the live process. - - Injection seam: tests replace this function with a fake that returns a - mock process whose stdout yields pre-recorded event lines. - - The flags mirror the golden agent (codex.py in the golden agent repo): - --json machine-readable newline-delimited events - --skip-git-repo-check safe to run outside a git repo - --dangerously-bypass-approvals-and-sandbox - skip interactive approval prompts in a - non-interactive (server) context - --model which OpenAI model to use - - The caller writes the prompt to stdin after the process starts, then - closes stdin so codex knows input is complete. - """ - cmd = [ - "codex", - "exec", - "--json", - "--skip-git-repo-check", - "--dangerously-bypass-approvals-and-sandbox", - "--model", - model, - "-", # read prompt from stdin - ] - return await asyncio.create_subprocess_exec( - *cmd, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - # Discard stderr: codex --json writes events to stdout; its stderr is - # progress/debug noise. Capturing it with PIPE but never reading it - # would deadlock once codex fills the OS pipe buffer (~64 KB). - stderr=asyncio.subprocess.DEVNULL, - env={**os.environ}, - ) - - -async def _process_stdout(process: asyncio.subprocess.Process) -> AsyncIterator[str]: - """Yield newline-delimited JSON lines from the process stdout. - - Uses an incremental UTF-8 decoder so a multibyte character split across two - 4 KB reads is decoded correctly instead of being corrupted at the boundary. - """ - assert process.stdout is not None - decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") - buffer = "" - while True: - chunk = await process.stdout.read(4096) - if not chunk: - break - buffer += decoder.decode(chunk) - while "\n" in buffer: - line, buffer = buffer.split("\n", 1) - line = line.strip() - if line: - yield line - buffer += decoder.decode(b"", final=True) - if buffer.strip(): - yield buffer.strip() - - -@acp.on_message_send -async def handle_message_send( - params: SendMessageParams, -) -> TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]: - """Handle each message by running ``codex exec`` locally and streaming events.""" - task_id = params.task.id - user_message = params.content.content - logger.info("Processing message for task %s", task_id) - - start_ms = int(time.monotonic() * 1000) - - async with adk.tracing.span( - trace_id=task_id, - task_id=task_id, - name="message", - input={"message": user_message}, - data={"__span_type__": "AGENT_WORKFLOW"}, - ) as turn_span: - process = await _spawn_codex(MODEL) - - # Write prompt to stdin then close it so codex knows input is done. - assert process.stdin is not None - process.stdin.write(user_message.encode("utf-8")) - await process.stdin.drain() - process.stdin.close() - - turn = CodexTurn( - events=_process_stdout(process), - model=MODEL, - ) - - emitter = UnifiedEmitter( - task_id=task_id, - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - ) - - async for event in emitter.yield_turn(turn): - yield event - - await process.wait() - - # Record the real wall-clock duration AFTER streaming completes; setting - # it before the stream ran would capture only subprocess spawn overhead. - turn.duration_ms = int(time.monotonic() * 1000) - start_ms - - if turn_span: - usage = turn.usage() - turn_span.output = { - "model": usage.model, - "input_tokens": usage.input_tokens, - "output_tokens": usage.output_tokens, - } diff --git a/examples/tutorials/00_sync/070_codex/pyproject.toml b/examples/tutorials/00_sync/070_codex/pyproject.toml deleted file mode 100644 index 88bbb9cca..000000000 --- a/examples/tutorials/00_sync/070_codex/pyproject.toml +++ /dev/null @@ -1,38 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "s070-codex" -version = "0.1.0" -description = "Sync tutorial agent driving the unified harness surface via local codex CLI subprocess" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "agentex-sdk", - "scale-gp", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "httpx", - "black", - "isort", - "flake8", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] - -[tool.black] -line-length = 88 -target-version = ['py312'] - -[tool.isort] -profile = "black" -line_length = 88 - -[tool.pytest.ini_options] -asyncio_mode = "auto" diff --git a/examples/tutorials/00_sync/070_codex/tests/test_agent.py b/examples/tutorials/00_sync/070_codex/tests/test_agent.py deleted file mode 100644 index 94aa2aaf2..000000000 --- a/examples/tutorials/00_sync/070_codex/tests/test_agent.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Tests for the sync Codex harness tutorial agent. - -LIVE tests (``TestLiveCodexAgent``): - - Require the ``codex`` CLI on PATH and ``OPENAI_API_KEY`` set. - - Run the full agent end-to-end against a live Agentex server. - - Skipped automatically when ``CODEX_LIVE_TESTS`` is not set to ``1``. - -OFFLINE unit tests (``TestOfflineCodexHandler``): - - Inject a fake async iterator of pre-recorded codex event lines. - - Assert the ``CodexTurn`` + ``UnifiedEmitter`` pipeline yields events, - populates usage, and satisfies the ``HarnessTurn`` protocol. - - Always run. -""" - -from __future__ import annotations - -import os -import json -from typing import Any - -import pytest - -# --------------------------------------------------------------------------- -# Shared helpers -# --------------------------------------------------------------------------- - -SAMPLE_EVENTS: list[dict[str, Any]] = [ - {"type": "thread.started", "thread_id": "thread-abc"}, - {"type": "turn.started"}, - { - "type": "item.started", - "item": {"id": "msg-1", "type": "agent_message", "text": "Hello"}, - }, - { - "type": "item.completed", - "item": {"id": "msg-1", "type": "agent_message", "text": "Hello, world!"}, - }, - { - "type": "turn.completed", - "usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, - }, -] - - -async def _fake_event_stream(): - """Async iterator of pre-recorded codex event JSON lines (no subprocess).""" - for evt in SAMPLE_EVENTS: - yield json.dumps(evt) - - -class TestOfflineCodexHandler: - """Unit tests that run without a real codex CLI or network.""" - - @pytest.mark.asyncio - async def test_codex_turn_yields_stream_events(self): - """CodexTurn drives the unified surface and yields StreamTaskMessage* events.""" - from agentex.lib.adk import CodexTurn - from agentex.lib.core.harness import UnifiedEmitter - - turn = CodexTurn(events=_fake_event_stream(), model="o4-mini") - emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) - - events = [e async for e in emitter.yield_turn(turn)] - assert len(events) > 0, "No events yielded" - - types_seen = {type(e).__name__ for e in events} - known_types = { - "StreamTaskMessageStart", - "StreamTaskMessageDelta", - "StreamTaskMessageFull", - "StreamTaskMessageDone", - } - assert bool(types_seen & known_types), f"Unexpected event types: {types_seen}" - - @pytest.mark.asyncio - async def test_usage_populated_after_stream_exhausted(self): - """CodexTurn.usage() returns correct tokens after stream is exhausted.""" - from agentex.lib.adk import CodexTurn - - turn = CodexTurn(events=_fake_event_stream(), model="o4-mini") - - collected = [e async for e in turn.events] - - usage = turn.usage() - assert usage.input_tokens == 10 - assert usage.output_tokens == 5 - assert usage.total_tokens == 15 - assert usage.model == "o4-mini" - - @pytest.mark.asyncio - async def test_codex_turn_protocol_compliance(self): - """CodexTurn satisfies the HarnessTurn protocol.""" - from agentex.lib.adk import CodexTurn - from agentex.lib.core.harness.types import HarnessTurn - - turn = CodexTurn(events=_fake_event_stream(), model="o4-mini") - assert isinstance(turn, HarnessTurn), "CodexTurn does not satisfy HarnessTurn protocol" - - @pytest.mark.asyncio - async def test_unified_emitter_yield_passes_through_events(self): - """UnifiedEmitter.yield_turn passes events through unchanged in sync mode.""" - from agentex.lib.adk import CodexTurn - from agentex.lib.core.harness import UnifiedEmitter - - turn = CodexTurn(events=_fake_event_stream(), model="o4-mini") - emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) - - events = [e async for e in emitter.yield_turn(turn)] - assert len(events) > 0 - - @pytest.mark.asyncio - async def test_convert_codex_to_agentex_events_direct(self): - """convert_codex_to_agentex_events tap produces text start/done events.""" - from agentex.lib.adk import convert_codex_to_agentex_events - from agentex.types.task_message_update import StreamTaskMessageDone - - events = [e async for e in convert_codex_to_agentex_events(_fake_event_stream())] - assert any(isinstance(e, StreamTaskMessageDone) for e in events), ( - "Expected at least one StreamTaskMessageDone event" - ) - - @pytest.mark.asyncio - async def test_on_result_callback_receives_session_id(self): - """on_result callback receives the session_id from thread.started.""" - from agentex.lib.adk import convert_codex_to_agentex_events - - captured: list[dict] = [] - - events = [ - e - async for e in convert_codex_to_agentex_events( - _fake_event_stream(), - on_result=captured.append, - ) - ] - - assert len(captured) == 1 - assert captured[0]["session_id"] == "thread-abc" - assert captured[0]["tool_call_count"] == 0 - - -# --------------------------------------------------------------------------- -# Live tests (skipped unless CODEX_LIVE_TESTS=1) -# --------------------------------------------------------------------------- - -LIVE = os.environ.get("CODEX_LIVE_TESTS", "") == "1" -AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") -AGENT_NAME = os.environ.get("AGENT_NAME", "s070-codex") - - -@pytest.mark.skipif(not LIVE, reason="Set CODEX_LIVE_TESTS=1 and ensure codex CLI + OPENAI_API_KEY are available") -class TestLiveCodexAgent: - """End-to-end tests that require the real codex CLI and a running Agentex server.""" - - @pytest.fixture - def client(self): - from agentex import Agentex - - return Agentex(base_url=AGENTEX_API_BASE_URL) - - def test_send_simple_message(self, client): - from agentex.types import TextContentParam - from agentex.types.agent_rpc_params import ParamsSendMessageRequest - - response = client.agents.send_message( - agent_name=AGENT_NAME, - params=ParamsSendMessageRequest( - content=TextContentParam( - author="user", - content="What is 2+2? Reply with just the number.", - type="text", - ) - ), - ) - assert response.result is not None - assert len(response.result) >= 1 diff --git a/examples/tutorials/10_async/00_base/000_hello_acp/.dockerignore b/examples/tutorials/10_async/00_base/000_hello_acp/.dockerignore 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 3c66248e8..000000000 --- a/examples/tutorials/10_async/00_base/030_tracing/README.md +++ /dev/null @@ -1,86 +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. - -## Token Usage & Cost Tracking - -Token usage on spans is what the backend bills from, and it reads two shapes: - -- **Per-turn aggregate** โ€” `span.data["usage"]` + `span.data["cost_usd"]`. Emit at most - once per turn, holding that turn's own usage (not a session-cumulative total). When a - trace has an aggregate, the backend keeps it and de-dups all per-call spans against it. -- **Per-call detail** โ€” `span.output["usage"]`. Optional; the SDK's LLM adapters - (litellm, OpenAI Agents SDK, LangGraph) emit this automatically. Summed only when no - aggregate exists in the trace. - -Record the turn rollup with `adk.tracing.turn_span()` instead of hand-writing usage keys. -It accepts the harness `TurnUsage` that every turn adapter reports (`LangGraphTurn.usage()`, -`run_turn(...).usage`, `ClaudeCodeTurn.usage()`, ...), cost included: - -```python -async with adk.tracing.turn_span( - trace_id=task.id, - name="turn", - input={"prompt": prompt}, - task_id=task.id, -) as turn: - result = await run_turn(...) - turn.output = {"response": result.final_output} - turn.record_usage(result.usage) # TurnUsage; cost_usd stamped automatically -``` - -**Never put usage on both a rollup span's `output` and its per-call children's -`output`** โ€” that double-counts. `turn_span` writes the aggregate to `data`, so child -spans stay safe to emit. Recognized token keys: `input_tokens`/`prompt_tokens`, -`output_tokens`/`completion_tokens`, `cached_input_tokens`/`cached_tokens`, -`reasoning_tokens`; cost is `cost_usd`. - -## When to Use -- Debugging complex agent behaviors -- Performance optimization and bottleneck identification -- Production monitoring and observability -- Understanding execution flow in multi-step agents - -## Why This Matters -Without tracing, debugging agents is like flying blind. Tracing gives you visibility into what your agent is doing, how long operations take, and where failures occur. It's essential for production agents and invaluable during development. - -**Next:** [040_other_sdks](../040_other_sdks/) - Integrate any SDK or framework diff --git a/examples/tutorials/10_async/00_base/030_tracing/dev.ipynb b/examples/tutorials/10_async/00_base/030_tracing/dev.ipynb 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 cd2fa6dd6..000000000 --- a/examples/tutorials/10_async/00_base/100_langgraph/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# Tutorial: Async LangGraph Agent - -This tutorial demonstrates how to build an **async** LangGraph agent on AgentEx -using the **unified harness surface**: - -```python -turn = LangGraphTurn(stream, model=None) -emitter = UnifiedEmitter(task_id=task_id, trace_id=task_id, ...) -result = await emitter.auto_send_turn(turn) -``` - -The `LangGraphTurn` + `UnifiedEmitter.auto_send_turn` path replaces calling the -lower-level ``stream_langgraph_events`` helper directly. - -## Key Concepts - -### Unified Harness - -`LangGraphTurn` implements the `HarnessTurn` protocol: it wraps the raw -LangGraph `astream()` generator and exposes `events` (an async generator of -`TaskMessageUpdate`) and `usage()` (token counts captured from the final -`AIMessage`). - -`UnifiedEmitter.auto_send_turn(turn)` pushes each event to Redis via -`streaming_task_message_context`, accumulates the final text, and returns a -`TurnResult(final_text=..., usage=...)`. - -The same `LangGraphTurn` object can also be passed to -`UnifiedEmitter.yield_turn` in the sync channel. - -### AGX1-377 Note - -LangGraph emits tool requests as `StreamTaskMessageFull` events (from "updates" -node outputs). The `SpanDeriver` does not open tool spans from Full events -today; that gap is tracked in AGX1-373. - -## Files - -| File | Description | -|------|-------------| -| `project/acp.py` | ACP server using unified harness (LangGraphTurn + auto_send_turn) | -| `project/graph.py` | LangGraph state graph (weather example) | -| `project/tools.py` | Tool definitions (weather example) | -| `tests/test_agent.py` | Integration tests | -| `manifest.yaml` | Agent configuration (name: ab100-langgraph) | - -## Running Locally - -```bash -agentex agents run -``` - -## Running Tests - -```bash -pytest tests/test_agent.py -v -``` diff --git a/examples/tutorials/10_async/00_base/100_langgraph/manifest.yaml b/examples/tutorials/10_async/00_base/100_langgraph/manifest.yaml deleted file mode 100644 index 13d64f524..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 using the unified harness surface (LangGraphTurn + UnifiedEmitter.auto_send_turn) - - temporal: - enabled: false - - credentials: - - env_var_name: OPENAI_API_KEY - secret_name: openai-api-key - secret_key: api-key - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - - env_var_name: SGP_API_KEY - secret_name: sgp-api-key - secret_key: api-key - - env_var_name: SGP_ACCOUNT_ID - secret_name: sgp-account-id - secret_key: account-id - - env_var_name: SGP_CLIENT_BASE_URL - secret_name: sgp-client-base-url - secret_key: url - -deployment: - image: - repository: "" - tag: "latest" - - global: - agent: - name: "ab100-langgraph" - description: "An async LangGraph agent using the unified harness surface" - replicaCount: 1 - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" diff --git a/examples/tutorials/10_async/00_base/100_langgraph/project/__init__.py b/examples/tutorials/10_async/00_base/100_langgraph/project/__init__.py 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 198446607..000000000 --- a/examples/tutorials/10_async/00_base/100_langgraph/project/acp.py +++ /dev/null @@ -1,109 +0,0 @@ -"""ACP handler for the async LangGraph agent. - -Uses the unified harness surface: ``LangGraphTurn`` wraps the LangGraph -``astream()`` generator, and ``UnifiedEmitter.auto_send_turn`` streams events -to Redis and returns a ``TurnResult`` with the accumulated final text. - -Properties of the unified surface: -- Tracing is wired through the tracing manager (no bespoke handler boilerplate). -- A single ``UnifiedEmitter.auto_send_turn(LangGraphTurn(stream))`` call - replaces bespoke event-streaming helpers. -- Tool calls/responses go through ``streaming_task_message_context`` - (same code path as text deltas), making the event stream channel-agnostic. -- Usage data (token counts) is captured on ``LangGraphTurn.usage()`` after - ``auto_send_turn`` returns. - -AGX1-377 note: LangGraph emits tool requests as ``StreamTaskMessageFull`` -events (from "updates"). The ``SpanDeriver`` does not open tool spans from -Full events today; that gap is tracked in AGX1-373. -""" - -from __future__ import annotations - -import os - -from dotenv import load_dotenv - -load_dotenv() - -import agentex.lib.adk as adk -from project.graph import create_graph -from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams -from agentex.lib.types.fastacp import AsyncACPConfig -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.lib.sdk.fastacp.fastacp import FastACP -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.lib.adk._modules._langgraph_turn import LangGraphTurn -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config - -logger = make_logger(__name__) - -add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SGP_API_KEY", ""), - sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - ) -) - -acp = FastACP.create( - acp_type="async", - config=AsyncACPConfig(type="base"), -) - -_graph = None - - -async def get_graph(): - global _graph - if _graph is None: - _graph = await create_graph() - return _graph - - -@acp.on_task_event_send -async def handle_task_event_send(params: SendEventParams): - """Handle incoming events, streaming tokens and tool calls via unified harness.""" - graph = await get_graph() - task_id = params.task.id - user_message = params.event.content.content - - logger.info(f"Processing message for thread {task_id}") - - await adk.messages.create(task_id=task_id, content=params.event.content) - - async with adk.tracing.span( - trace_id=task_id, - task_id=task_id, - name="message", - input={"message": user_message}, - data={"__span_type__": "AGENT_WORKFLOW"}, - ) as turn_span: - stream = graph.astream( - {"messages": [{"role": "user", "content": user_message}]}, - config={"configurable": {"thread_id": task_id}}, - stream_mode=["messages", "updates"], - ) - - turn = LangGraphTurn(stream, model=None) - emitter = UnifiedEmitter( - task_id=task_id, - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - ) - - result = await emitter.auto_send_turn(turn) - - if turn_span: - turn_span.output = {"final_output": result.final_text} - - -@acp.on_task_create -async def handle_task_create(params: CreateTaskParams): - logger.info(f"Task created: {params.task.id}") - - -@acp.on_task_cancel -async def handle_task_canceled(params: CancelTaskParams): - logger.info(f"Task canceled: {params.task.id}") diff --git a/examples/tutorials/10_async/00_base/100_langgraph/project/graph.py b/examples/tutorials/10_async/00_base/100_langgraph/project/graph.py deleted file mode 100644 index d63f28390..000000000 --- a/examples/tutorials/10_async/00_base/100_langgraph/project/graph.py +++ /dev/null @@ -1,67 +0,0 @@ -"""LangGraph graph definition for the 100_langgraph async agent. - -Identical to ``100_langgraph/project/graph.py`` โ€” the graph definition is not -affected by the harness migration. Only ``acp.py`` changes. -""" - -from __future__ import annotations - -from typing import Any, Annotated -from datetime import datetime -from typing_extensions import TypedDict - -from langgraph.graph import START, StateGraph -from langchain_openai import ChatOpenAI -from langgraph.prebuilt import ToolNode, tools_condition -from langchain_core.messages import SystemMessage -from langgraph.graph.message import add_messages - -from project.tools import TOOLS -from agentex.lib.adk import create_checkpointer - -MODEL_NAME = "gpt-5" -SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. - -Current date and time: {timestamp} - -Guidelines: -- Be concise and helpful -- Use tools when they would help answer the user's question -- If you're unsure, ask clarifying questions -- Always provide accurate information -""" - - -class AgentState(TypedDict): - """State schema for the agent graph.""" - - messages: Annotated[list[Any], add_messages] - - -async def create_graph(): - """Create and compile the agent graph with checkpointer.""" - llm = ChatOpenAI( - model=MODEL_NAME, - reasoning={"effort": "high", "summary": "auto"}, - ) - llm_with_tools = llm.bind_tools(TOOLS) - - checkpointer = await create_checkpointer() - - def agent_node(state: AgentState) -> dict[str, Any]: - """Process the current state and generate a response.""" - messages = state["messages"] - if not messages or not isinstance(messages[0], SystemMessage): - system_content = SYSTEM_PROMPT.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")) - messages = [SystemMessage(content=system_content)] + messages - response = llm_with_tools.invoke(messages) - return {"messages": [response]} - - builder = StateGraph(AgentState) - builder.add_node("agent", agent_node) - builder.add_node("tools", ToolNode(tools=TOOLS)) - builder.add_edge(START, "agent") - builder.add_conditional_edges("agent", tools_condition, "tools") - builder.add_edge("tools", "agent") - - return builder.compile(checkpointer=checkpointer) diff --git a/examples/tutorials/10_async/00_base/100_langgraph/project/tools.py b/examples/tutorials/10_async/00_base/100_langgraph/project/tools.py deleted file mode 100644 index e421528fc..000000000 --- a/examples/tutorials/10_async/00_base/100_langgraph/project/tools.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Tool definitions for the 100_langgraph async agent.""" - -from langchain_core.tools import Tool - - -def get_weather(city: str) -> str: - """Get the current weather for a city. - - Args: - city: The name of the city to get weather for. - - Returns: - A string describing the weather conditions. - """ - return f"The weather in {city} is sunny and 72ยฐF" - - -weather_tool = Tool( - name="get_weather", - func=get_weather, - description="Get the current weather for a city. Input should be a city name.", -) - -TOOLS = [weather_tool] diff --git a/examples/tutorials/10_async/00_base/100_langgraph/pyproject.toml b/examples/tutorials/10_async/00_base/100_langgraph/pyproject.toml deleted file mode 100644 index 715477bac..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 using the unified harness surface" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "agentex-sdk", - "scale-gp", - "langgraph", - "langchain-openai", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "httpx", - "black", - "isort", - "flake8", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] - -[tool.black] -line-length = 88 -target-version = ['py312'] - -[tool.isort] -profile = "black" -line_length = 88 diff --git a/examples/tutorials/10_async/00_base/100_langgraph/tests/test_agent.py b/examples/tutorials/10_async/00_base/100_langgraph/tests/test_agent.py deleted file mode 100644 index b80d7a8f9..000000000 --- a/examples/tutorials/10_async/00_base/100_langgraph/tests/test_agent.py +++ /dev/null @@ -1,100 +0,0 @@ -""" -Tests for the async harness LangGraph agent. - -Validates the unified harness surface (LangGraphTurn + UnifiedEmitter.auto_send_turn) -end-to-end against a live AgentEx server. - -Configuration: -- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) -- AGENT_NAME: Name of the agent to test (default: ab100-langgraph) -""" - -import os - -import pytest -import pytest_asyncio - -from agentex import AsyncAgentex -from agentex.types import TextContentParam -from agentex.types.agent_rpc_params import ParamsCreateTaskRequest -from agentex.lib.sdk.fastacp.base.base_acp_server import uuid - -AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") -AGENT_NAME = os.environ.get("AGENT_NAME", "ab100-langgraph") - - -@pytest_asyncio.fixture -async def client(): - client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) - yield client - await client.close() - - -@pytest.fixture -def agent_name(): - return AGENT_NAME - - -@pytest_asyncio.fixture -async def agent_id(client, agent_name): - agents = await client.agents.list() - for agent in agents: - if agent.name == agent_name: - return agent.id - raise ValueError(f"Agent with name {agent_name} not found.") - - -class TestNonStreamingEvents: - @pytest.mark.asyncio - async def test_send_event(self, client: AsyncAgentex, agent_id: str): - task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) - task = task_response.result - assert task is not None - - event_content = TextContentParam( - type="text", - author="user", - content="Hello! What can you help me with?", - ) - await client.agents.send_event( - agent_id=agent_id, - params={"task_id": task.id, "content": event_content}, - ) - - @pytest.mark.asyncio - async def test_tool_calling(self, client: AsyncAgentex, agent_id: str): - task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) - task = task_response.result - assert task is not None - - event_content = TextContentParam( - type="text", - author="user", - content="What's the weather in San Francisco?", - ) - await client.agents.send_event( - agent_id=agent_id, - params={"task_id": task.id, "content": event_content}, - ) - - -class TestStreamingEvents: - @pytest.mark.asyncio - async def test_send_event_and_stream(self, client: AsyncAgentex, agent_id: str): - task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) - task = task_response.result - assert task is not None - - event_content = TextContentParam( - type="text", - author="user", - content="Tell me a short joke.", - ) - await client.agents.send_event( - agent_id=agent_id, - params={"task_id": task.id, "content": event_content}, - ) - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/00_base/110_pydantic_ai/.dockerignore b/examples/tutorials/10_async/00_base/110_pydantic_ai/.dockerignore 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 db56979cc..000000000 --- a/examples/tutorials/10_async/00_base/110_pydantic_ai/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Async Pydantic AI Agent - -A minimal **async** (Redis-streaming) Pydantic AI agent that drives the -**unified harness surface** (`UnifiedEmitter.auto_send_turn` + `PydanticAITurn`) -directly. - -## Why this agent exists - -This agent calls `emitter.auto_send_turn(...)` **explicitly** at the -agent-author level, making the unified-surface wiring visible and giving the -async channel direct coverage. - -## How it wires the unified surface - -In `project/acp.py`: - -```python -emitter = UnifiedEmitter( - task_id=task_id, - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, -) -async with agent.run_stream_events(user_message, message_history=previous_messages) as stream: - turn = PydanticAITurn(tee_messages(stream), model=MODEL_NAME, coalesce_tool_requests=True) - result = await emitter.auto_send_turn(turn) -``` - -- `coalesce_tool_requests=True` is required on the async/auto_send path until - AGX1-377 lands: tool requests are delivered as a single `Full(tool_request)` - rather than streamed `Start + Delta + Done`. -- The `UnifiedEmitter` is constructed from the ACP context (`task_id` + - `trace_id` + `parent_span_id`) so messages auto-send to the task stream - (Redis) and tracing is automatic. -- Multi-turn memory is persisted via `adk.state` (pydantic-ai message history - round-tripped through `ModelMessagesTypeAdapter`). - -## Files - -- `project/acp.py` โ€” async ACP handler using `emitter.auto_send_turn(...)`. -- `project/agent.py` โ€” builds the `pydantic_ai.Agent` with one tool. -- `project/tools.py` โ€” `get_weather(city)` returning a constant. -- `tests/test_agent.py` โ€” live integration test (requires a running agent). - -## Tools - -- `get_weather(city: str) -> str`: returns a fixed "sunny and 72ยฐF" string. - -## Offline coverage - -Offline integration tests for the same wiring (pydantic-ai `TestModel` + fake -streaming/tracing, no network) live in the SDK repo under -`tests/lib/core/harness/` (the pydantic-ai async suite). diff --git a/examples/tutorials/10_async/00_base/110_pydantic_ai/manifest.yaml b/examples/tutorials/10_async/00_base/110_pydantic_ai/manifest.yaml deleted file mode 100644 index 4aca13d44..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 harness test agent using the unified emitter surface - - temporal: - enabled: false - - credentials: - - env_var_name: OPENAI_API_KEY - secret_name: openai-api-key - secret_key: api-key - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - - env_var_name: SGP_API_KEY - secret_name: sgp-api-key - secret_key: api-key - - env_var_name: SGP_ACCOUNT_ID - secret_name: sgp-account-id - secret_key: account-id - - env_var_name: SGP_CLIENT_BASE_URL - secret_name: sgp-client-base-url - secret_key: url - -deployment: - image: - repository: "" - tag: "latest" - - global: - agent: - name: "ab110-pydantic-ai" - description: "An async Pydantic AI harness test agent using the unified emitter surface" - replicaCount: 1 - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" diff --git a/examples/tutorials/10_async/00_base/110_pydantic_ai/project/__init__.py b/examples/tutorials/10_async/00_base/110_pydantic_ai/project/__init__.py 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 95b638f8b..000000000 --- a/examples/tutorials/10_async/00_base/110_pydantic_ai/project/acp.py +++ /dev/null @@ -1,159 +0,0 @@ -"""ACP handler for the async harness Pydantic AI test agent. - -This agent exercises the UNIFIED HARNESS SURFACE on the async (Redis-streaming) -channel โ€” ``UnifiedEmitter.auto_send_turn(PydanticAITurn(...))`` -โ€” calling it directly rather than via the ``stream_pydantic_ai_events`` helper -(which the ``110_pydantic_ai`` tutorial uses). This makes the unified-surface -wiring explicit at the agent-author level. - -Multi-turn memory is persisted via ``adk.state``: on each turn we load the -previous pydantic-ai ``message_history`` from state, run the agent with it, -then save the updated history back. -""" - -from __future__ import annotations - -import os -from typing import Any, AsyncIterator - -from dotenv import load_dotenv - -load_dotenv() - -from pydantic_ai.run import AgentRunResultEvent -from pydantic_ai.messages import ModelMessagesTypeAdapter - -import agentex.lib.adk as adk -from project.agent import MODEL_NAME, create_agent -from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams -from agentex.lib.core.harness import UnifiedEmitter -from agentex.lib.types.fastacp import AsyncACPConfig -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.lib.utils.model_utils import BaseModel -from agentex.lib.sdk.fastacp.fastacp import FastACP -from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config - -logger = make_logger(__name__) - -add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SGP_API_KEY", ""), - sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - ) -) - -acp = FastACP.create( - acp_type="async", - config=AsyncACPConfig(type="base"), -) - -_agent = None - - -def get_agent(): - global _agent - if _agent is None: - _agent = create_agent() - return _agent - - -class ConversationState(BaseModel): - """Per-task conversation state persisted via ``adk.state``. - - ``history_json`` holds the pydantic-ai message history serialized by - ``ModelMessagesTypeAdapter`` โ€” pydantic-ai's official way to round-trip - ``ModelMessage`` objects through JSON. - """ - - history_json: str = "[]" - turn_number: int = 0 - - -@acp.on_task_create -async def handle_task_create(params: CreateTaskParams): - """Initialize per-task state on task creation.""" - logger.info(f"Task created: {params.task.id}") - await adk.state.create( - task_id=params.task.id, - agent_id=params.agent.id, - state=ConversationState(), - ) - - -@acp.on_task_event_send -async def handle_task_event_send(params: SendEventParams): - """Handle each user message through the unified auto_send_turn path.""" - agent = get_agent() - task_id = params.task.id - agent_id = params.agent.id - user_message = params.event.content.content - - logger.info(f"Processing message for thread {task_id}") - - # Echo the user's message into the task history. - await adk.messages.create(task_id=task_id, content=params.event.content) - - # Load the previous conversation history from state (fall back to fresh). - task_state = await adk.state.get_by_task_and_agent(task_id=task_id, agent_id=agent_id) - if task_state is None: - state = ConversationState() - task_state = await adk.state.create(task_id=task_id, agent_id=agent_id, state=state) - else: - state = ConversationState.model_validate(task_state.state) - - state.turn_number += 1 - previous_messages = ModelMessagesTypeAdapter.validate_json(state.history_json) - - async with adk.tracing.span( - trace_id=task_id, - task_id=task_id, - name=f"Turn {state.turn_number}", - input={"message": user_message}, - data={"__span_type__": "AGENT_WORKFLOW"}, - ) as turn_span: - # Construct the UnifiedEmitter from the ACP context so tracing is - # automatic and messages are auto-sent to the task stream (Redis). - emitter = UnifiedEmitter( - task_id=task_id, - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - ) - - # Capture the terminal AgentRunResultEvent to persist message history. - captured_messages: list[Any] = [] - - async def tee_messages(upstream) -> AsyncIterator[Any]: - async for event in upstream: - if isinstance(event, AgentRunResultEvent): - captured_messages[:] = list(event.result.all_messages()) - yield event - - async with agent.run_stream_events(user_message, message_history=previous_messages) as stream: - # The unified auto_send path delivers streamed tool requests natively - # (Start+Delta+Done), so no coalescing workaround is needed. - turn = PydanticAITurn( - tee_messages(stream), - model=MODEL_NAME, - ) - result = await emitter.auto_send_turn(turn) - - # Save the updated message history so the next turn picks up here. - if captured_messages: - state.history_json = ModelMessagesTypeAdapter.dump_json(captured_messages).decode() - await adk.state.update( - state_id=task_state.id, - task_id=task_id, - agent_id=agent_id, - state=state, - ) - - if turn_span: - turn_span.output = {"final_output": result.final_text} - - -@acp.on_task_cancel -async def handle_task_canceled(params: CancelTaskParams): - logger.info(f"Task canceled: {params.task.id}") diff --git a/examples/tutorials/10_async/00_base/110_pydantic_ai/project/agent.py b/examples/tutorials/10_async/00_base/110_pydantic_ai/project/agent.py deleted file mode 100644 index e7b764d82..000000000 --- a/examples/tutorials/10_async/00_base/110_pydantic_ai/project/agent.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Pydantic AI agent definition for the async harness test agent. - -The Agent is the boundary between this module and the API layer (acp.py). -Pydantic AI handles its own tool-call loop internally โ€” no graph required. -""" - -from __future__ import annotations - -from datetime import datetime - -from pydantic_ai import Agent - -from project.tools import get_weather - -__all__ = ["create_agent", "MODEL_NAME"] - -MODEL_NAME = "openai:gpt-4o-mini" -SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. - -Current date and time: {timestamp} - -Guidelines: -- Be concise and helpful -- Use tools when they would help answer the user's question -- If you're unsure, ask clarifying questions -- Always provide accurate information -""" - - -def create_agent() -> Agent: - """Build and return the Pydantic AI agent with tools registered.""" - agent = Agent( - MODEL_NAME, - system_prompt=SYSTEM_PROMPT.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")), - ) - - agent.tool_plain(get_weather) - - return agent diff --git a/examples/tutorials/10_async/00_base/110_pydantic_ai/project/tools.py b/examples/tutorials/10_async/00_base/110_pydantic_ai/project/tools.py deleted file mode 100644 index 0f16a7cb0..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 harness Pydantic AI agent. - -Pydantic AI tools are registered directly on the Agent via decorators -(see project.agent). This module hosts the bare function so it is easy to -unit-test in isolation. -""" - -from __future__ import annotations - - -def get_weather(city: str) -> str: - """Get the current weather for a city. - - Args: - city: The name of the city to get weather for. - - Returns: - A string describing the weather conditions. - """ - return f"The weather in {city} is sunny and 72ยฐF" diff --git a/examples/tutorials/10_async/00_base/110_pydantic_ai/pyproject.toml b/examples/tutorials/10_async/00_base/110_pydantic_ai/pyproject.toml deleted file mode 100644 index 257918014..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 harness test agent using the unified emitter surface" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "agentex-sdk", - "scale-gp", - "pydantic-ai-slim[openai]>=1.0,<2", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "httpx", - "black", - "isort", - "flake8", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] - -[tool.black] -line-length = 88 -target-version = ['py312'] - -[tool.isort] -profile = "black" -line_length = 88 diff --git a/examples/tutorials/10_async/00_base/110_pydantic_ai/tests/test_agent.py b/examples/tutorials/10_async/00_base/110_pydantic_ai/tests/test_agent.py deleted file mode 100644 index ce573a697..000000000 --- a/examples/tutorials/10_async/00_base/110_pydantic_ai/tests/test_agent.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Live tests for the async Pydantic AI agent. - -These tests require a running agent (server + deployed agent) and exercise the -unified-surface async handler end-to-end over the wire. - -Offline coverage of the same wiring (TestModel + fake streaming/tracing) lives -in the SDK repo under ``tests/lib/core/harness/`` (the pydantic-ai async suite). - -To run these tests: -1. Make sure the agent is running (via docker-compose or `agentex agents run`) -2. Set the AGENTEX_API_BASE_URL environment variable if not using default -3. Run: pytest test_agent.py -v - -Configuration: -- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) -- AGENT_NAME: Name of the agent to test (default: ab110-pydantic-ai) -""" - -import os - -import pytest -import pytest_asyncio - -from agentex import AsyncAgentex -from agentex.types import TextContentParam -from agentex.types.agent_rpc_params import ParamsCreateTaskRequest -from agentex.lib.sdk.fastacp.base.base_acp_server import uuid - -AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") -AGENT_NAME = os.environ.get("AGENT_NAME", "ab110-pydantic-ai") - - -@pytest_asyncio.fixture -async def client(): - """Create an AsyncAgentex client instance for testing.""" - client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) - yield client - await client.close() - - -@pytest.fixture -def agent_name(): - """Return the agent name for testing.""" - return AGENT_NAME - - -@pytest_asyncio.fixture -async def agent_id(client, agent_name): - """Retrieve the agent ID based on the agent name.""" - agents = await client.agents.list() - for agent in agents: - if agent.name == agent_name: - return agent.id - raise ValueError(f"Agent with name {agent_name} not found.") - - -class TestNonStreamingEvents: - """Test non-streaming event sending through the unified auto_send_turn path.""" - - @pytest.mark.asyncio - async def test_send_event(self, client: AsyncAgentex, agent_id: str): - """Test sending an event to the async harness Pydantic AI agent.""" - task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) - task = task_response.result - assert task is not None - - event_content = TextContentParam( - type="text", - author="user", - content="Hello! What can you help me with?", - ) - await client.agents.send_event( - agent_id=agent_id, - params={"task_id": task.id, "content": event_content}, - ) - - @pytest.mark.asyncio - async def test_tool_calling(self, client: AsyncAgentex, agent_id: str): - """Test that the agent can use tools (e.g., weather tool).""" - task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) - task = task_response.result - assert task is not None - - event_content = TextContentParam( - type="text", - author="user", - content="What's the weather in San Francisco?", - ) - await client.agents.send_event( - agent_id=agent_id, - params={"task_id": task.id, "content": event_content}, - ) - - -class TestStreamingEvents: - """Test streaming event sending.""" - - @pytest.mark.asyncio - async def test_send_event_and_stream(self, client: AsyncAgentex, agent_id: str): - """Test sending an event and streaming the response.""" - task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) - task = task_response.result - assert task is not None - - event_content = TextContentParam( - type="text", - author="user", - content="Tell me a short joke.", - ) - await client.agents.send_event( - agent_id=agent_id, - params={"task_id": task.id, "content": event_content}, - ) - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/00_base/120_openai_agents/.dockerignore b/examples/tutorials/10_async/00_base/120_openai_agents/.dockerignore deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents/.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/Dockerfile b/examples/tutorials/10_async/00_base/120_openai_agents/Dockerfile deleted file mode 100644 index 76fe0fdef..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents/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/pyproject.toml /app/120_openai_agents/pyproject.toml -COPY 10_async/00_base/120_openai_agents/README.md /app/120_openai_agents/README.md - -WORKDIR /app/120_openai_agents - -# Copy the project code -COPY 10_async/00_base/120_openai_agents/project /app/120_openai_agents/project - -# Copy the test files -COPY 10_async/00_base/120_openai_agents/tests /app/120_openai_agents/tests - -# Copy shared test utilities -COPY test_utils /app/test_utils - -# Install the required Python packages with dev dependencies -RUN uv pip install --system .[dev] pytest-asyncio httpx - -# Set environment variables -ENV PYTHONPATH=/app - -# Set test environment variables -ENV AGENT_NAME=ab120-openai-agents - -# Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_async/00_base/120_openai_agents/README.md b/examples/tutorials/10_async/00_base/120_openai_agents/README.md deleted file mode 100644 index 0b55b00a2..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# Async OpenAI Agents on the unified harness surface - -An async (Redis-streaming) Agentex agent that runs the OpenAI Agents SDK and -delivers its output through the **unified harness surface**. - -## What this demonstrates - -Same `OpenAITurn` adapter as the sync tutorial (`050_openai_agents`), but the -async ACP pushes the turn to the task stream via -`UnifiedEmitter.auto_send_turn` instead of yielding over HTTP. `auto_send_turn` -returns a `TurnResult` with the accumulated final text and normalized usage. - -```python -result = Runner.run_streamed(starting_agent=agent, input=user_message) -turn = OpenAITurn(result=result, model="gpt-4o") -emitter = UnifiedEmitter(task_id=task_id, trace_id=task_id, parent_span_id=parent_span_id) -turn_result = await emitter.auto_send_turn(turn) -``` - -## Run it - -```bash -agentex agents run --manifest manifest.yaml -``` - -## Test it - -The offline test exercises the auto-send delivery path with an injected fake -streaming backend (no server, Redis, or API key required): - -```bash -pytest tests/test_agent.py -v -``` diff --git a/examples/tutorials/10_async/00_base/120_openai_agents/manifest.yaml b/examples/tutorials/10_async/00_base/120_openai_agents/manifest.yaml deleted file mode 100644 index bd8d5cce5..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents/manifest.yaml +++ /dev/null @@ -1,58 +0,0 @@ -build: - context: - root: ../../../ - include_paths: - - 10_async/00_base/120_openai_agents - - test_utils - dockerfile: 10_async/00_base/120_openai_agents/Dockerfile - dockerignore: 10_async/00_base/120_openai_agents/.dockerignore - -local_development: - agent: - port: 8000 - host_address: host.docker.internal - paths: - acp: project/acp.py - -agent: - acp_type: async - name: ab120-openai-agents - description: An async OpenAI Agents SDK agent on the unified harness surface - - temporal: - enabled: false - - credentials: - - env_var_name: OPENAI_API_KEY - secret_name: openai-api-key - secret_key: api-key - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - - env_var_name: SGP_API_KEY - secret_name: sgp-api-key - secret_key: api-key - - env_var_name: SGP_ACCOUNT_ID - secret_name: sgp-account-id - secret_key: account-id - - env_var_name: SGP_CLIENT_BASE_URL - secret_name: sgp-client-base-url - secret_key: url - -deployment: - image: - repository: "" - tag: "latest" - - global: - agent: - name: "ab120-openai-agents" - description: "An async OpenAI Agents SDK agent on the unified harness surface" - replicaCount: 1 - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" diff --git a/examples/tutorials/10_async/00_base/120_openai_agents/project/__init__.py b/examples/tutorials/10_async/00_base/120_openai_agents/project/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/10_async/00_base/120_openai_agents/project/acp.py b/examples/tutorials/10_async/00_base/120_openai_agents/project/acp.py deleted file mode 100644 index fcd10cc62..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents/project/acp.py +++ /dev/null @@ -1,98 +0,0 @@ -"""ACP handler for the async OpenAI Agents harness tutorial. - -Uses the async ACP model with Redis streaming instead of HTTP yields. The -OpenAI Agents SDK run is wrapped in an ``OpenAITurn`` and pushed to the task -stream via ``UnifiedEmitter.auto_send_turn`` โ€” the async/temporal delivery path -of the unified harness surface. ``auto_send_turn`` returns a ``TurnResult`` -carrying the accumulated final text and normalized usage. -""" - -from __future__ import annotations - -import os - -from dotenv import load_dotenv - -load_dotenv() - -from agents import Runner - -from agentex.lib import adk -from project.agent import MODEL_NAME, create_agent -from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams -from agentex.lib.types.fastacp import AsyncACPConfig -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.lib.sdk.fastacp.fastacp import FastACP -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.lib.adk.providers._modules.openai_turn import OpenAITurn -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config - -logger = make_logger(__name__) - -_litellm_key = os.environ.get("LITELLM_API_KEY") -if _litellm_key and not os.environ.get("OPENAI_API_KEY"): - os.environ["OPENAI_API_KEY"] = _litellm_key - -add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SGP_API_KEY", ""), - sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - ) -) - -acp = FastACP.create( - acp_type="async", - config=AsyncACPConfig(type="base"), -) - -_agent = None - - -def get_agent(): - global _agent - if _agent is None: - _agent = create_agent() - return _agent - - -@acp.on_task_create -async def handle_task_create(params: CreateTaskParams): - logger.info(f"Task created: {params.task.id}") - - -@acp.on_task_event_send -async def handle_task_event_send(params: SendEventParams): - """Handle each user message: run the agent and auto-send its turn.""" - agent = get_agent() - task_id = params.task.id - user_message = params.event.content.content - - logger.info(f"Processing message for task {task_id}") - - # Echo the user's message into the task history. - await adk.messages.create(task_id=task_id, content=params.event.content) - - async with adk.tracing.span( - trace_id=task_id, - task_id=task_id, - name="message", - input={"message": user_message}, - data={"__span_type__": "AGENT_WORKFLOW"}, - ) as turn_span: - result = Runner.run_streamed(starting_agent=agent, input=user_message) - turn = OpenAITurn(result=result, model=MODEL_NAME) - emitter = UnifiedEmitter( - task_id=task_id, - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - ) - turn_result = await emitter.auto_send_turn(turn) - if turn_span: - turn_span.output = {"final_output": turn_result.final_text} - - -@acp.on_task_cancel -async def handle_task_canceled(params: CancelTaskParams): - logger.info(f"Task canceled: {params.task.id}") diff --git a/examples/tutorials/10_async/00_base/120_openai_agents/project/agent.py b/examples/tutorials/10_async/00_base/120_openai_agents/project/agent.py deleted file mode 100644 index 5b83c5aab..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents/project/agent.py +++ /dev/null @@ -1,43 +0,0 @@ -"""OpenAI Agents SDK agent definition for the async harness tutorial. - -Identical agent shape to the sync tutorial (060). The only difference is the -delivery path in acp.py: the async ACP uses ``UnifiedEmitter.auto_send_turn`` -(Redis streaming) instead of yielding events over an HTTP response. -""" - -from __future__ import annotations - -from datetime import datetime - -from agents import Agent, function_tool, set_tracing_disabled - -from project.tools import get_weather - -set_tracing_disabled(True) - -MODEL_NAME = "gpt-4o" -INSTRUCTIONS = """You are a helpful AI assistant with access to tools. - -Current date and time: {timestamp} - -Guidelines: -- Be concise and helpful -- Use the weather tool when the user asks about the weather -- Always report the real tool output back to the user -""" - - -@function_tool -def weather(city: str) -> str: - """Get the current weather for a city.""" - return get_weather(city) - - -def create_agent() -> Agent: - """Build and return the OpenAI Agents SDK agent with the weather tool.""" - return Agent( - name="Harness OpenAI Assistant", - model=MODEL_NAME, - instructions=INSTRUCTIONS.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")), - tools=[weather], - ) diff --git a/examples/tutorials/10_async/00_base/120_openai_agents/project/tools.py b/examples/tutorials/10_async/00_base/120_openai_agents/project/tools.py deleted file mode 100644 index d2e5468c9..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents/project/tools.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Tool definitions for the async OpenAI Agents harness tutorial.""" - -from __future__ import annotations - - -def get_weather(city: str) -> str: - """Get the current weather for a city. - - Args: - city: The name of the city to get weather for. - - Returns: - A string describing the weather conditions. - """ - return f"The weather in {city} is sunny and 72ยฐF" diff --git a/examples/tutorials/10_async/00_base/120_openai_agents/pyproject.toml b/examples/tutorials/10_async/00_base/120_openai_agents/pyproject.toml deleted file mode 100644 index f48fab49f..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents/pyproject.toml +++ /dev/null @@ -1,36 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "ab120-openai-agents" -version = "0.1.0" -description = "An async OpenAI Agents SDK agent on the unified harness surface" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "agentex-sdk", - "scale-gp", - "openai-agents", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "httpx", - "black", - "isort", - "flake8", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] - -[tool.black] -line-length = 88 -target-version = ['py312'] - -[tool.isort] -profile = "black" -line_length = 88 diff --git a/examples/tutorials/10_async/00_base/120_openai_agents/tests/test_agent.py b/examples/tutorials/10_async/00_base/120_openai_agents/tests/test_agent.py deleted file mode 100644 index ceb95dbab..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents/tests/test_agent.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Offline test for the async OpenAI Agents harness tutorial. - -This test does NOT require a running Agentex server, Redis, or an OpenAI API -key. It verifies the async delivery path this tutorial demonstrates: an -``OpenAITurn`` built from an injected canonical stream, pushed through -``UnifiedEmitter.auto_send_turn`` with an injected fake streaming backend, -returns the accumulated final text. - -To run: ``pytest tests/test_agent.py -v`` -""" - -from __future__ import annotations - -import pytest - -from agentex.types.task_message import TaskMessage -from agentex.types.text_content import TextContent -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.types.task_message_delta import TextDelta -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.lib.adk.providers._modules.openai_turn import OpenAITurn - - -class _FakeCtx: - def __init__(self, initial_content): - self.task_message = TaskMessage(id="m-1", task_id="task-1", content=initial_content) - - async def __aenter__(self): - return self - - async def __aexit__(self, *a): - await self.close() - return False - - async def close(self): - pass - - async def stream_update(self, update): - return update - - -class _FakeStreaming: - def streaming_task_message_context(self, task_id, initial_content, **_kwargs): # noqa: ARG002 - return _FakeCtx(initial_content) - - -async def _canonical_stream(events): - for e in events: - yield e - - -@pytest.mark.asyncio -async def test_auto_send_turn_returns_final_text(): - events = [ - StreamTaskMessageStart(type="start", index=0, content=TextContent(type="text", author="agent", content="")), - StreamTaskMessageDelta(type="delta", index=0, delta=TextDelta(type="text", text_delta="Hel")), - StreamTaskMessageDelta(type="delta", index=0, delta=TextDelta(type="text", text_delta="lo")), - StreamTaskMessageDone(type="done", index=0), - ] - turn = OpenAITurn(stream=_canonical_stream(events), model="gpt-4o") - emitter = UnifiedEmitter( - task_id="task-1", - trace_id=None, - parent_span_id=None, - streaming=_FakeStreaming(), - ) - - result = await emitter.auto_send_turn(turn) - assert result.final_text == "Hello" - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/00_base/130_claude_code/.dockerignore b/examples/tutorials/10_async/00_base/130_claude_code/.dockerignore deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/10_async/00_base/130_claude_code/.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/130_claude_code/Dockerfile b/examples/tutorials/10_async/00_base/130_claude_code/Dockerfile deleted file mode 100644 index e36b9e56d..000000000 --- a/examples/tutorials/10_async/00_base/130_claude_code/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/ - -RUN apt-get update && apt-get install -y \ - htop \ - vim \ - curl \ - tar \ - python3-dev \ - postgresql-client \ - build-essential \ - libpq-dev \ - gcc \ - cmake \ - netcat-openbsd \ - nodejs \ - npm \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -RUN uv pip install --system --upgrade pip setuptools wheel - -RUN npm install -g @anthropic-ai/claude-code || true - -ENV UV_HTTP_TIMEOUT=1000 - -COPY 10_async/00_base/130_claude_code/pyproject.toml /app/130_claude_code/pyproject.toml -COPY 10_async/00_base/130_claude_code/README.md /app/130_claude_code/README.md - -WORKDIR /app/130_claude_code - -COPY 10_async/00_base/130_claude_code/project /app/130_claude_code/project -COPY 10_async/00_base/130_claude_code/tests /app/130_claude_code/tests -COPY test_utils /app/test_utils - -RUN uv pip install --system .[dev] - -ENV PYTHONPATH=/app - -ENV AGENT_NAME=ab130-claude-code - -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_async/00_base/130_claude_code/README.md b/examples/tutorials/10_async/00_base/130_claude_code/README.md deleted file mode 100644 index 695207c57..000000000 --- a/examples/tutorials/10_async/00_base/130_claude_code/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# Tutorial 130 (async/base): Async Claude Code Agent - -This tutorial demonstrates how to build an **async (non-Temporal)** agent that -spawns the Claude Code CLI as a local subprocess and delivers its output through -the Agentex unified harness surface via ``ClaudeCodeTurn`` and -``UnifiedEmitter.auto_send_turn``. - -## Key Concepts - -### Async delivery path - -Unlike the sync tutorial (060), this agent uses the async ACP model. The -``@acp.on_task_event_send`` handler does not return a generator -- instead, -``UnifiedEmitter.auto_send_turn(turn)`` pushes events to the task's Redis -stream in real time and returns a ``TurnResult`` when the turn is complete. -The UI polls or streams that Redis channel independently. - -### ClaudeCodeTurn + UnifiedEmitter - -Same tap as the sync tutorial: -- ``ClaudeCodeTurn`` wraps ``convert_claude_code_to_agentex_events``. -- ``UnifiedEmitter`` wires trace context + chosen delivery. -- ``auto_send_turn`` is the async push path. - -### Local subprocess spawn - -``_spawn_claude`` in ``project/acp.py`` uses ``asyncio.create_subprocess_exec`` -to run: - -``` -claude -p --output-format stream-json --verbose -``` - -The prompt is written to stdin. Stdout is read line by line. - -Production isolation (Scale sandbox, secret injection, MCP configuration) -is the golden agent's concern at -``teams/sgp/agents/golden_agent/project/harness/providers/claude.py``. - -### Injectable spawn seam - -``_spawn_claude`` is a top-level async generator. Tests monkeypatch it to -inject pre-recorded stream-json lines so offline unit tests run without the CLI. - -## Files - -| File | Description | -|------|-------------| -| ``project/acp.py`` | ACP server, ``_spawn_claude`` seam, and event handler | -| ``tests/test_agent.py`` | Live integration tests (needs CLI + API key) | -| ``tests/test_agent_offline.py`` | Offline unit tests with injected fake subprocess | -| ``manifest.yaml`` | Agent configuration | - -## Running Locally (live) - -Requires the ``claude`` CLI installed and ``ANTHROPIC_API_KEY`` set: - -```bash -npm install -g @anthropic-ai/claude-code -export ANTHROPIC_API_KEY=sk-ant-... -agentex agents run -``` - -## Running Offline Tests - -No CLI or API key needed: - -```bash -uv run pytest tests/test_agent_offline.py -v -``` - -## Notes - -- Production isolation (sandbox, secrets, MCP) is the golden agent's concern. -- For multi-turn memory, persist the Claude Code session_id from the - ``result`` envelope and pass it to ``claude -r `` on the next turn. diff --git a/examples/tutorials/10_async/00_base/130_claude_code/manifest.yaml b/examples/tutorials/10_async/00_base/130_claude_code/manifest.yaml deleted file mode 100644 index 7d74de7c6..000000000 --- a/examples/tutorials/10_async/00_base/130_claude_code/manifest.yaml +++ /dev/null @@ -1,58 +0,0 @@ -build: - context: - root: ../../../ - include_paths: - - 10_async/00_base/130_claude_code - - test_utils - dockerfile: 10_async/00_base/130_claude_code/Dockerfile - dockerignore: 10_async/00_base/130_claude_code/.dockerignore - -local_development: - agent: - port: 8000 - host_address: host.docker.internal - paths: - acp: project/acp.py - -agent: - acp_type: async - name: ab130-claude-code - description: An async Claude Code agent streaming the unified harness surface via a local CLI subprocess - - temporal: - enabled: false - - credentials: - - env_var_name: ANTHROPIC_API_KEY - secret_name: anthropic-api-key - secret_key: api-key - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - - env_var_name: SGP_API_KEY - secret_name: sgp-api-key - secret_key: api-key - - env_var_name: SGP_ACCOUNT_ID - secret_name: sgp-account-id - secret_key: account-id - - env_var_name: SGP_CLIENT_BASE_URL - secret_name: sgp-client-base-url - secret_key: url - -deployment: - image: - repository: "" - tag: "latest" - - global: - agent: - name: "ab130-claude-code" - description: "An async Claude Code agent streaming via local CLI subprocess" - replicaCount: 1 - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" diff --git a/examples/tutorials/10_async/00_base/130_claude_code/project/__init__.py b/examples/tutorials/10_async/00_base/130_claude_code/project/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/10_async/00_base/130_claude_code/project/acp.py b/examples/tutorials/10_async/00_base/130_claude_code/project/acp.py deleted file mode 100644 index b6681f6a8..000000000 --- a/examples/tutorials/10_async/00_base/130_claude_code/project/acp.py +++ /dev/null @@ -1,149 +0,0 @@ -"""ACP handler for the async Claude Code tutorial. - -Spawns ``claude -p --output-format stream-json --verbose`` as a LOCAL -asyncio subprocess (no Scale sandbox -- that is the golden agent's -production concern). Stdout lines are fed into ``ClaudeCodeTurn``. Events -are delivered via ``UnifiedEmitter.auto_send_turn``, the async Redis push -path. - -Live runs require the ``claude`` CLI to be installed and an -ANTHROPIC_API_KEY (or equivalent credential) in the environment. -For offline testing, see ``tests/test_agent_offline.py``. -""" - -from __future__ import annotations - -import os -import asyncio -from typing import AsyncIterator - -from dotenv import load_dotenv - -load_dotenv() - -import agentex.lib.adk as adk -from agentex.lib.adk import ClaudeCodeTurn -from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams -from agentex.lib.core.harness import UnifiedEmitter -from agentex.lib.types.fastacp import AsyncACPConfig -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.lib.sdk.fastacp.fastacp import FastACP -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config - -logger = make_logger(__name__) - -add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SGP_API_KEY", ""), - sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - ) -) - -acp = FastACP.create( - acp_type="async", - config=AsyncACPConfig(type="base"), -) - - -async def _spawn_claude(prompt: str) -> AsyncIterator[str]: - """Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines. - - Injectable seam: tests monkeypatch this with a fake async iterator of - pre-recorded lines so no real CLI invocation is needed offline. - """ - proc = await asyncio.create_subprocess_exec( - "claude", - "-p", - "--output-format", - "stream-json", - "--verbose", - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - assert proc.stdout is not None - assert proc.stdin is not None - - proc.stdin.write(prompt.encode()) - proc.stdin.close() - - # Drain stderr concurrently. With --verbose, Claude Code can write enough to - # stderr to fill the OS pipe buffer; if we only read stdout, the CLI blocks - # on its stderr write while we block reading stdout โ€” a deadlock. A - # background task keeps stderr flowing so stdout never stalls. - async def _drain_stderr() -> None: - assert proc.stderr is not None - async for _ in proc.stderr: - pass - - stderr_task = asyncio.create_task(_drain_stderr()) - - try: - buffer = "" - async for chunk in proc.stdout: - buffer += chunk.decode("utf-8", errors="replace") - while "\n" in buffer: - line, buffer = buffer.split("\n", 1) - line = line.strip() - if line: - yield line - - if buffer.strip(): - yield buffer.strip() - - await proc.wait() - finally: - # Release the subprocess and stderr drain task even if the consumer - # abandons the generator early (task cancellation / client disconnect): - # cancel the drain task and terminate+reap the process if it is still - # running, so neither is leaked. - stderr_task.cancel() - try: - await stderr_task - except asyncio.CancelledError: - pass - if proc.returncode is None: - try: - proc.terminate() - except ProcessLookupError: - pass - await proc.wait() - - -@acp.on_task_create -async def handle_task_create(params: CreateTaskParams): - logger.info("Task created: %s", params.task.id) - - -@acp.on_task_event_send -async def handle_task_event_send(params: SendEventParams): - """Handle a user message: spawn Claude Code locally and push events to the task stream.""" - task_id = params.task.id - prompt = params.event.content.content - logger.info("Processing message for task %s", task_id) - - await adk.messages.create(task_id=task_id, content=params.event.content) - - async with adk.tracing.span( - trace_id=task_id, - task_id=task_id, - name="message", - input={"message": prompt}, - data={"__span_type__": "AGENT_WORKFLOW"}, - ) as turn_span: - emitter = UnifiedEmitter( - task_id=task_id, - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - ) - turn = ClaudeCodeTurn(_spawn_claude(prompt)) - result = await emitter.auto_send_turn(turn) - if turn_span: - turn_span.output = {"final_text": result.final_text} - - -@acp.on_task_cancel -async def handle_task_canceled(params: CancelTaskParams): - logger.info("Task canceled: %s", params.task.id) diff --git a/examples/tutorials/10_async/00_base/130_claude_code/pyproject.toml b/examples/tutorials/10_async/00_base/130_claude_code/pyproject.toml deleted file mode 100644 index 66c3cdaf3..000000000 --- a/examples/tutorials/10_async/00_base/130_claude_code/pyproject.toml +++ /dev/null @@ -1,25 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "ab130-claude-code" -version = "0.1.0" -description = "An async Claude Code agent streaming the unified harness surface via a local CLI subprocess" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "agentex-sdk", - "scale-gp", - "python-dotenv>=1.0,<2", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "httpx", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] diff --git a/examples/tutorials/10_async/00_base/130_claude_code/tests/test_agent.py b/examples/tutorials/10_async/00_base/130_claude_code/tests/test_agent.py deleted file mode 100644 index ee254da23..000000000 --- a/examples/tutorials/10_async/00_base/130_claude_code/tests/test_agent.py +++ /dev/null @@ -1,250 +0,0 @@ -"""Tests for the async Claude Code tutorial agent. - -LIVE tests (``TestClaudeCodeLive``): - - Require the ``claude`` CLI on PATH and ``ANTHROPIC_API_KEY`` set. - - Run the full agent end-to-end against a live Agentex server. - - Skipped automatically when ``CLAUDE_LIVE_TESTS`` is not set to ``1``. - -OFFLINE unit tests (``TestClaudeCodeOffline``): - - Inject a fake async iterator of pre-recorded stream-json lines. - - Assert the ``ClaudeCodeTurn`` + ``UnifiedEmitter`` pipeline drives - ``auto_send_turn``, populates usage, and satisfies the ``HarnessTurn`` - protocol. - - Always run -- no CLI or API key needed. -""" - -from __future__ import annotations - -import os -import json -from typing import AsyncIterator - -import pytest - -from agentex.types.task_message import TaskMessage - -# --------------------------------------------------------------------------- -# Recorded stream-json fixtures -# --------------------------------------------------------------------------- - -_TEXT_ONLY_LINES: list[str] = [ - json.dumps({"type": "system", "subtype": "init", "session_id": "sess-offline-async-1"}), - json.dumps( - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "Hello from async Claude Code!"}]}, - } - ), - json.dumps( - { - "type": "result", - "usage": {"input_tokens": 12, "output_tokens": 6}, - "cost_usd": 0.0001, - "duration_ms": 300, - "num_turns": 1, - } - ), -] - - -async def _fake_lines(lines: list[str]) -> AsyncIterator[str]: - """Async iterator of pre-recorded stream-json lines (no subprocess).""" - for line in lines: - yield line - - -# --------------------------------------------------------------------------- -# Fake streaming backend -# --------------------------------------------------------------------------- - - -class _FakeCtx: - def __init__(self, sink, content_type, initial_content): - self.sink = sink - self.content_type = content_type - self.task_message = TaskMessage(id="msg-1", task_id="task-offline", content=initial_content) - - async def __aenter__(self): - self.sink.append(("open", self.content_type)) - return self - - async def __aexit__(self, *a): - await self.close() - return False - - async def close(self): - self.sink.append(("close", self.content_type)) - - async def stream_update(self, update): - self.sink.append(("update", update)) - return update - - -class _FakeStreaming: - def __init__(self): - self.sink: list = [] - - def streaming_task_message_context(self, task_id, initial_content, streaming_mode="coalesced", created_at=None): # noqa: ARG002 - ctype = getattr(initial_content, "type", None) - self.sink.append(("ctx", ctype)) - return _FakeCtx(self.sink, ctype, initial_content) - - -# --------------------------------------------------------------------------- -# Offline tests (always run -- no CLI or API key needed) -# --------------------------------------------------------------------------- - - -class TestClaudeCodeOffline: - """Unit tests that run without a real claude CLI or network.""" - - @pytest.mark.asyncio - async def test_auto_send_text_only_opens_and_closes_context(self): - """auto_send_turn opens and closes exactly one streaming context.""" - from agentex.lib.adk import ClaudeCodeTurn - from agentex.lib.core.harness import UnifiedEmitter - - fake_streaming = _FakeStreaming() - turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) - emitter = UnifiedEmitter( - task_id="offline-task", - trace_id=None, - parent_span_id=None, - tracer=False, - streaming=fake_streaming, - ) - result = await emitter.auto_send_turn(turn) - - opened = [s for s in fake_streaming.sink if s[0] == "open"] - closed = [s for s in fake_streaming.sink if s[0] == "close"] - assert len(opened) == 1 - assert len(closed) == 1 - assert opened[0][1] == "text" - - @pytest.mark.asyncio - async def test_auto_send_populates_final_text(self): - """auto_send_turn result carries the agent's reply text.""" - from agentex.lib.adk import ClaudeCodeTurn - from agentex.lib.core.harness import UnifiedEmitter - - fake_streaming = _FakeStreaming() - turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) - emitter = UnifiedEmitter( - task_id="offline-task", - trace_id=None, - parent_span_id=None, - tracer=False, - streaming=fake_streaming, - ) - result = await emitter.auto_send_turn(turn) - assert "Hello from async Claude Code" in result.final_text - - @pytest.mark.asyncio - async def test_usage_populated_after_stream_exhausted(self): - """Usage is populated after the events stream is exhausted.""" - from agentex.lib.adk import ClaudeCodeTurn - from agentex.lib.core.harness import UnifiedEmitter - - fake_streaming = _FakeStreaming() - turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) - emitter = UnifiedEmitter( - task_id="t", - trace_id=None, - parent_span_id=None, - tracer=False, - streaming=fake_streaming, - ) - await emitter.auto_send_turn(turn) - usage = turn.usage() - assert usage.input_tokens == 12 - assert usage.output_tokens == 6 - assert usage.num_llm_calls == 1 - - @pytest.mark.asyncio - async def test_stream_task_message_done_present(self): - """StreamTaskMessageDone must appear via yield_turn on a ClaudeCodeTurn.""" - from agentex.lib.adk import ClaudeCodeTurn - from agentex.lib.core.harness import UnifiedEmitter - from agentex.types.task_message_update import StreamTaskMessageDone - - turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) - emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) - events = [e async for e in emitter.yield_turn(turn)] - assert any(isinstance(e, StreamTaskMessageDone) for e in events), ( - "Expected at least one StreamTaskMessageDone event" - ) - - -# --------------------------------------------------------------------------- -# Live tests (skipped unless CLAUDE_LIVE_TESTS=1) -# --------------------------------------------------------------------------- - -pytestmark_live = pytest.mark.skipif( - not os.environ.get("CLAUDE_LIVE_TESTS"), - reason="Set CLAUDE_LIVE_TESTS=1 and ensure the `claude` CLI + ANTHROPIC_API_KEY are available", -) - -AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") -AGENT_NAME = os.environ.get("AGENT_NAME", "ab130-claude-code") - - -@pytestmark_live -class TestClaudeCodeLive: - """Live async tests -- needs the claude CLI + ANTHROPIC_API_KEY.""" - - @pytest.fixture - def client(self): - from agentex import Agentex - - return Agentex(base_url=AGENTEX_API_BASE_URL) - - @pytest.fixture - def agent_name(self): - return AGENT_NAME - - @pytest.fixture - def agent_id(self, client, agent_name): - agents = client.agents.list() - for agent in agents: - if agent.name == agent_name: - return agent.id - raise ValueError(f"Agent {agent_name!r} not found.") - - def test_send_simple_message(self, client, agent_id: str): - """Create a task, send a message, and poll until a response appears.""" - import time - import uuid - - from agentex.types import TextContentParam - from agentex.types.agent_rpc_params import ParamsSendEventRequest, ParamsCreateTaskRequest - - task = client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)).result - assert task is not None - task_id = task.id - - client.agents.send_event( - agent_id=agent_id, - params=ParamsSendEventRequest( - task_id=task_id, - content=TextContentParam( - author="user", - content="Reply with exactly three words: hello from claude", - type="text", - ), - ), - ) - - deadline = time.monotonic() + 60 - while time.monotonic() < deadline: - msgs = client.messages.list(task_id=task_id) - agent_msgs = [m for m in msgs if getattr(m.content, "author", None) == "agent"] - if agent_msgs: - assert len(agent_msgs) >= 1 - return - time.sleep(2) - - raise AssertionError("No agent response received within 60 s") - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/00_base/130_claude_code/tests/test_agent_offline.py b/examples/tutorials/10_async/00_base/130_claude_code/tests/test_agent_offline.py deleted file mode 100644 index ac48474ee..000000000 --- a/examples/tutorials/10_async/00_base/130_claude_code/tests/test_agent_offline.py +++ /dev/null @@ -1,243 +0,0 @@ -"""Offline unit tests for the async Claude Code tutorial agent. - -These tests do NOT require the ``claude`` CLI or an ANTHROPIC_API_KEY. -They inject a fake async iterator of pre-recorded stream-json lines in -place of the real subprocess spawn and a fake streaming backend, then -assert that the handler drives ``UnifiedEmitter.auto_send_turn`` correctly. - -The injection seam is the ``_spawn_claude`` function in ``project/acp.py``. -""" - -from __future__ import annotations - -import json -from typing import AsyncIterator - -import pytest - -from agentex.lib.adk import ClaudeCodeTurn -from agentex.lib.core.harness import UnifiedEmitter -from agentex.types.task_message import TaskMessage - -# --------------------------------------------------------------------------- -# Recorded fixtures -# --------------------------------------------------------------------------- - -_TEXT_ONLY_LINES: list[str] = [ - json.dumps({"type": "system", "subtype": "init", "session_id": "sess-1"}), - json.dumps( - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "Hello from async Claude Code!"}]}, - } - ), - json.dumps( - { - "type": "result", - "usage": {"input_tokens": 12, "output_tokens": 6}, - "cost_usd": 0.0001, - "duration_ms": 300, - "num_turns": 1, - } - ), -] - -_TOOL_CALL_LINES: list[str] = [ - json.dumps({"type": "system", "subtype": "init", "session_id": "sess-2"}), - json.dumps( - { - "type": "assistant", - "message": { - "content": [ - { - "type": "tool_use", - "id": "tool_xyz", - "name": "Read", - "input": {"file_path": "/tmp/foo.txt"}, - } - ] - }, - } - ), - json.dumps( - { - "type": "user", - "message": { - "content": [ - { - "type": "tool_result", - "tool_use_id": "tool_xyz", - "content": "file contents", - "is_error": False, - } - ] - }, - } - ), - json.dumps( - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "Read the file."}]}, - } - ), - json.dumps( - { - "type": "result", - "usage": {"input_tokens": 25, "output_tokens": 10}, - "cost_usd": 0.0003, - "duration_ms": 500, - "num_turns": 1, - } - ), -] - - -# --------------------------------------------------------------------------- -# Fake streaming backend -# --------------------------------------------------------------------------- - - -class _FakeCtx: - def __init__(self, sink, content_type, initial_content): - self.sink = sink - self.content_type = content_type - self.task_message = TaskMessage(id="msg-1", task_id="task-offline", content=initial_content) - - async def __aenter__(self): - self.sink.append(("open", self.content_type)) - return self - - async def __aexit__(self, *a): - await self.close() - return False - - async def close(self): - self.sink.append(("close", self.content_type)) - - async def stream_update(self, update): - self.sink.append(("update", update)) - return update - - -class _FakeStreaming: - def __init__(self): - self.sink: list = [] - - def streaming_task_message_context(self, task_id, initial_content, streaming_mode="coalesced", created_at=None): # noqa: ARG002 - ctype = getattr(initial_content, "type", None) - self.sink.append(("ctx", ctype)) - return _FakeCtx(self.sink, ctype, initial_content) - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -async def _fake_lines(lines: list[str]) -> AsyncIterator[str]: - for line in lines: - yield line - - -async def _run_auto_send(lines: list[str]): - """Drive ClaudeCodeTurn through auto_send_turn with a fake streaming backend.""" - fake_streaming = _FakeStreaming() - turn = ClaudeCodeTurn(_fake_lines(lines)) - emitter = UnifiedEmitter( - task_id="offline-task", - trace_id=None, - parent_span_id=None, - tracer=False, - streaming=fake_streaming, - ) - result = await emitter.auto_send_turn(turn) - return result, fake_streaming.sink - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_auto_send_text_only_opens_and_closes_context(): - result, sink = await _run_auto_send(_TEXT_ONLY_LINES) - opened = [s for s in sink if s[0] == "open"] - closed = [s for s in sink if s[0] == "close"] - assert len(opened) == 1 - assert len(closed) == 1 - assert opened[0][1] == "text" - - -@pytest.mark.asyncio -async def test_auto_send_populates_final_text(): - result, _ = await _run_auto_send(_TEXT_ONLY_LINES) - assert "Hello from async Claude Code" in result.final_text - - -@pytest.mark.asyncio -async def test_auto_send_usage_is_populated(): - """Usage is populated after the events stream is exhausted. - - UnifiedEmitter.auto_send_turn evaluates turn.usage() eagerly (before - the events are consumed) so the TurnResult.usage reflects a pre-exhaust - snapshot. Test usage directly from the turn after auto_send_turn completes - instead -- the result envelope is populated by the generator being consumed - inside auto_send. - """ - turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) - fake_streaming = _FakeStreaming() - emitter = UnifiedEmitter( - task_id="t", - trace_id=None, - parent_span_id=None, - tracer=False, - streaming=fake_streaming, - ) - await emitter.auto_send_turn(turn) - # After auto_send_turn, the events generator is exhausted and - # ClaudeCodeTurn._on_result has been called with the result envelope. - usage = turn.usage() - assert usage.input_tokens == 12 - assert usage.output_tokens == 6 - assert usage.num_llm_calls == 1 - - -@pytest.mark.asyncio -async def test_auto_send_tool_call_opens_two_contexts(): - result, sink = await _run_auto_send(_TOOL_CALL_LINES) - opened = [s for s in sink if s[0] == "open"] - content_types = [s[1] for s in opened] - assert "tool_request" in content_types - assert "text" in content_types - - -@pytest.mark.asyncio -async def test_spawn_seam_concept(): - """Demonstrate the injectable spawn seam pattern used in project/acp.py. - - The ``_spawn_claude`` function is a top-level async generator. A drop-in - replacement can be injected (e.g. via monkeypatch) to supply pre-recorded - lines without spawning the real CLI. This test proves the pattern works - end-to-end without importing the full ACP module. - """ - called: list[str] = [] - - async def _fake_spawn(prompt: str) -> AsyncIterator[str]: - called.append(prompt) - for line in _TEXT_ONLY_LINES: - yield line - - fake_streaming = _FakeStreaming() - turn = ClaudeCodeTurn(_fake_spawn("ping")) - emitter = UnifiedEmitter( - task_id="t", - trace_id=None, - parent_span_id=None, - tracer=False, - streaming=fake_streaming, - ) - result = await emitter.auto_send_turn(turn) - - assert called == ["ping"] - assert "Hello from async Claude Code" in result.final_text diff --git a/examples/tutorials/10_async/00_base/140_codex/.dockerignore b/examples/tutorials/10_async/00_base/140_codex/.dockerignore deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/10_async/00_base/140_codex/.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/140_codex/Dockerfile b/examples/tutorials/10_async/00_base/140_codex/Dockerfile deleted file mode 100644 index 0dd839d8c..000000000 --- a/examples/tutorials/10_async/00_base/140_codex/Dockerfile +++ /dev/null @@ -1,45 +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 the codex CLI: the agent spawns `codex exec --json`, so the binary -# must be present on PATH in the image. -RUN npm install -g @openai/codex - -RUN uv pip install --system --upgrade pip setuptools wheel - -ENV UV_HTTP_TIMEOUT=1000 - -COPY 10_async/00_base/140_codex/pyproject.toml /app/140_codex/pyproject.toml -COPY 10_async/00_base/140_codex/README.md /app/140_codex/README.md - -WORKDIR /app/140_codex - -COPY 10_async/00_base/140_codex/project /app/140_codex/project -COPY 10_async/00_base/140_codex/tests /app/140_codex/tests -COPY test_utils /app/test_utils - -RUN uv pip install --system .[dev] - -ENV PYTHONPATH=/app -ENV AGENT_NAME=ab140-codex - -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_async/00_base/140_codex/README.md b/examples/tutorials/10_async/00_base/140_codex/README.md deleted file mode 100644 index a00ddb562..000000000 --- a/examples/tutorials/10_async/00_base/140_codex/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# 140_codex (async base) - -Tutorial agent demonstrating the `convert_codex_to_agentex_events` tap, -`CodexTurn`, and `UnifiedEmitter` for an **async** (Redis-streaming, no Temporal) -ACP agent. - -## What this tutorial shows - -- Spawning `codex exec --json` as a **local asyncio subprocess** (no Scale sandbox). -- Wrapping the stdout line stream in a `CodexTurn`. -- Delivering every canonical `StreamTaskMessage*` event to Redis via - `UnifiedEmitter.auto_send_turn`, so the UI receives tokens in real time. -- Persisting the codex thread ID in `adk.state` so subsequent turns resume the - same codex session via `codex exec resume `. - -> **Production isolation note:** A tutorial agent runs the Codex CLI locally. -> Production-grade isolation (Scale sandbox, secret injection, MCP configuration) -> is handled by the golden agent at -> `teams/sgp/agents/golden_agent/project/harness/providers/codex.py`. - -## Live runs - -Live runs require: -1. The `codex` CLI on PATH: `npm install -g @openai/codex` -2. `OPENAI_API_KEY` set in the environment. - -## Running offline unit tests - -```bash -cd /path/to/scale-agentex-python -uv run --all-packages --all-extras pytest examples/tutorials/10_async/00_base/140_codex/tests/test_agent.py -q -``` - -## Running live integration tests - -```bash -export CODEX_LIVE_TESTS=1 -export OPENAI_API_KEY=sk-... -pytest tests/test_agent.py -v -``` diff --git a/examples/tutorials/10_async/00_base/140_codex/conftest.py b/examples/tutorials/10_async/00_base/140_codex/conftest.py deleted file mode 100644 index bdd78994b..000000000 --- a/examples/tutorials/10_async/00_base/140_codex/conftest.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Add the agent's project root to sys.path so ``import project`` works. - -Also sets minimal environment variables so the FastACP and tracing modules -can be imported without a running agent server. -""" - -import os -import sys - -sys.path.insert(0, os.path.dirname(__file__)) - -os.environ.setdefault("ACP_URL", "http://localhost:8000") diff --git a/examples/tutorials/10_async/00_base/140_codex/manifest.yaml b/examples/tutorials/10_async/00_base/140_codex/manifest.yaml deleted file mode 100644 index be020b141..000000000 --- a/examples/tutorials/10_async/00_base/140_codex/manifest.yaml +++ /dev/null @@ -1,58 +0,0 @@ -build: - context: - root: ../../../ - include_paths: - - 10_async/00_base/140_codex - - test_utils - dockerfile: 10_async/00_base/140_codex/Dockerfile - dockerignore: 10_async/00_base/140_codex/.dockerignore - -local_development: - agent: - port: 8000 - host_address: host.docker.internal - paths: - acp: project/acp.py - -agent: - acp_type: async - name: ab140-codex - description: Async (base) tutorial agent driving the unified harness surface via local codex CLI subprocess - - temporal: - enabled: false - - credentials: - - env_var_name: OPENAI_API_KEY - secret_name: openai-api-key - secret_key: api-key - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - - env_var_name: SGP_API_KEY - secret_name: sgp-api-key - secret_key: api-key - - env_var_name: SGP_ACCOUNT_ID - secret_name: sgp-account-id - secret_key: account-id - - env_var_name: SGP_CLIENT_BASE_URL - secret_name: sgp-client-base-url - secret_key: url - -deployment: - image: - repository: "" - tag: "latest" - - global: - agent: - name: "ab140-codex" - description: "Async (base) tutorial agent driving the unified harness surface via local codex CLI subprocess" - replicaCount: 1 - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" diff --git a/examples/tutorials/10_async/00_base/140_codex/project/__init__.py b/examples/tutorials/10_async/00_base/140_codex/project/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/10_async/00_base/140_codex/project/acp.py b/examples/tutorials/10_async/00_base/140_codex/project/acp.py deleted file mode 100644 index 0233c49ab..000000000 --- a/examples/tutorials/10_async/00_base/140_codex/project/acp.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Async (base) ACP handler for the Codex CLI harness tutorial. - -Demonstrates the ``convert_codex_to_agentex_events`` tap + ``CodexTurn`` + -``UnifiedEmitter`` for an async (Redis-streaming) ACP agent without Temporal. - -The handler: -1. Spawns ``codex exec --json`` as a LOCAL asyncio subprocess (no sandbox). - This is correct for tutorials and local development; production isolation - is handled by the golden agent's Scale sandbox at - ``teams/sgp/agents/golden_agent/project/harness/providers/codex.py``. -2. Wraps the stdout line stream in a ``CodexTurn``. -3. Delivers every canonical ``StreamTaskMessage*`` event to Redis via - ``UnifiedEmitter.auto_send_turn``, so the UI receives tokens in real time. -4. Multi-turn memory is persisted via ``adk.state``. - -Live runs require: -- ``codex`` CLI on PATH (``npm install -g @openai/codex``) -- ``OPENAI_API_KEY`` set in the environment -""" - -from __future__ import annotations - -import os -import time -import codecs -import asyncio -from collections.abc import AsyncIterator - -from dotenv import load_dotenv - -load_dotenv() - -import agentex.lib.adk as adk -from agentex.lib.adk import CodexTurn -from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams -from agentex.lib.core.harness import UnifiedEmitter -from agentex.lib.types.fastacp import AsyncACPConfig -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.lib.utils.model_utils import BaseModel -from agentex.lib.sdk.fastacp.fastacp import FastACP -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config - -logger = make_logger(__name__) - -add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SGP_API_KEY", ""), - sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - ) -) - -acp = FastACP.create( - acp_type="async", - config=AsyncACPConfig(type="base"), -) - -MODEL = os.environ.get("CODEX_MODEL", "o4-mini") - - -class ConversationState(BaseModel): - """Per-task conversation state persisted via ``adk.state``. - - We store the codex session/thread ID so subsequent turns can resume the - same codex session via ``codex exec resume ``. - """ - - codex_thread_id: str | None = None - turn_number: int = 0 - - -async def _spawn_codex( - model: str, - thread_id: str | None = None, -) -> asyncio.subprocess.Process: - """Spawn ``codex exec --json`` locally and return the live process. - - Injection seam: tests replace this function with a fake that returns a - mock process whose stdout yields pre-recorded event lines. - - When ``thread_id`` is provided the subcommand becomes - ``codex exec ... resume -`` so codex continues the prior - conversation thread. - - The caller writes the prompt to stdin after the process starts, then - closes stdin so codex knows input is complete. - """ - base_flags = [ - "--json", - "--skip-git-repo-check", - "--dangerously-bypass-approvals-and-sandbox", - "--model", - model, - ] - - if thread_id: - cmd = ["codex", "exec", *base_flags, "resume", thread_id, "-"] - else: - cmd = ["codex", "exec", *base_flags, "-"] - - return await asyncio.create_subprocess_exec( - *cmd, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - # Discard stderr: codex --json writes events to stdout; its stderr is - # progress/debug noise. Capturing it with PIPE but never reading it - # would deadlock once codex fills the OS pipe buffer (~64 KB). - stderr=asyncio.subprocess.DEVNULL, - env={**os.environ}, - ) - - -async def _process_stdout(process: asyncio.subprocess.Process) -> AsyncIterator[str]: - """Yield newline-delimited JSON lines from the process stdout. - - Uses an incremental UTF-8 decoder so a multibyte character split across two - 4 KB reads is decoded correctly instead of being corrupted at the boundary. - """ - assert process.stdout is not None - decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") - buffer = "" - while True: - chunk = await process.stdout.read(4096) - if not chunk: - break - buffer += decoder.decode(chunk) - while "\n" in buffer: - line, buffer = buffer.split("\n", 1) - line = line.strip() - if line: - yield line - buffer += decoder.decode(b"", final=True) - if buffer.strip(): - yield buffer.strip() - - -@acp.on_task_create -async def handle_task_create(params: CreateTaskParams): - """Initialize per-task state on task creation.""" - logger.info("Task created: %s", params.task.id) - await adk.state.create( - task_id=params.task.id, - agent_id=params.agent.id, - state=ConversationState(), - ) - - -@acp.on_task_event_send -async def handle_task_event_send(params: SendEventParams): - """Handle each user message: spawn codex, stream events, save thread ID.""" - task_id = params.task.id - agent_id = params.agent.id - user_message = params.event.content.content - - logger.info("Processing message for task %s", task_id) - - await adk.messages.create(task_id=task_id, content=params.event.content) - - task_state = await adk.state.get_by_task_and_agent(task_id=task_id, agent_id=agent_id) - if task_state is None: - state = ConversationState() - task_state = await adk.state.create(task_id=task_id, agent_id=agent_id, state=state) - else: - state = ConversationState.model_validate(task_state.state) - - state.turn_number += 1 - - async with adk.tracing.span( - trace_id=task_id, - task_id=task_id, - name=f"Turn {state.turn_number}", - input={"message": user_message}, - data={"__span_type__": "AGENT_WORKFLOW"}, - ) as turn_span: - start_ms = int(time.monotonic() * 1000) - - process = await _spawn_codex(MODEL, thread_id=state.codex_thread_id) - - assert process.stdin is not None - process.stdin.write(user_message.encode("utf-8")) - await process.stdin.drain() - process.stdin.close() - - turn = CodexTurn( - events=_process_stdout(process), - model=MODEL, - ) - - emitter = UnifiedEmitter( - task_id=task_id, - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - ) - - result = await emitter.auto_send_turn(turn) - - await process.wait() - - # Record the real wall-clock duration AFTER streaming completes; setting - # it before the stream ran would capture only subprocess spawn overhead. - turn.duration_ms = int(time.monotonic() * 1000) - start_ms - - # Persist the new thread ID so subsequent turns resume the same session. - usage = turn.usage() - if usage.model: - # usage() is valid now that the stream is exhausted - pass - # Persist the codex session id (public accessor; valid post-stream) so the - # next turn resumes the same session. - if turn.session_id: - state.codex_thread_id = turn.session_id - - await adk.state.update( - state_id=task_state.id, - task_id=task_id, - agent_id=agent_id, - state=state, - ) - - if turn_span: - turn_span.output = { - "final_text": result.final_text, - "model": usage.model, - } - - -@acp.on_task_cancel -async def handle_task_canceled(params: CancelTaskParams): - logger.info("Task canceled: %s", params.task.id) diff --git a/examples/tutorials/10_async/00_base/140_codex/pyproject.toml b/examples/tutorials/10_async/00_base/140_codex/pyproject.toml deleted file mode 100644 index bdf7c462f..000000000 --- a/examples/tutorials/10_async/00_base/140_codex/pyproject.toml +++ /dev/null @@ -1,38 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "ab140-codex" -version = "0.1.0" -description = "Async (base) tutorial agent driving the unified harness surface via local codex CLI subprocess" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "agentex-sdk", - "scale-gp", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "httpx", - "black", - "isort", - "flake8", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] - -[tool.black] -line-length = 88 -target-version = ['py312'] - -[tool.isort] -profile = "black" -line_length = 88 - -[tool.pytest.ini_options] -asyncio_mode = "auto" diff --git a/examples/tutorials/10_async/00_base/140_codex/tests/test_agent.py b/examples/tutorials/10_async/00_base/140_codex/tests/test_agent.py deleted file mode 100644 index 68ca5aded..000000000 --- a/examples/tutorials/10_async/00_base/140_codex/tests/test_agent.py +++ /dev/null @@ -1,188 +0,0 @@ -"""Tests for the async (base) Codex harness tutorial agent. - -LIVE tests (``TestLiveCodexAgent``): - - Require the ``codex`` CLI on PATH and ``OPENAI_API_KEY`` set. - - Skipped automatically when ``CODEX_LIVE_TESTS`` is not set to ``1``. - -OFFLINE unit tests (``TestOfflineCodexHandler``): - - Inject a fake async iterator of pre-recorded codex event lines. - - Assert ``CodexTurn`` + ``UnifiedEmitter.auto_send_turn`` is driven correctly. - - Always run. -""" - -from __future__ import annotations - -import os -import json -from typing import Any -from unittest.mock import AsyncMock, MagicMock - -import pytest - -# --------------------------------------------------------------------------- -# Shared helpers -# --------------------------------------------------------------------------- - -SAMPLE_EVENTS: list[dict[str, Any]] = [ - {"type": "thread.started", "thread_id": "thread-xyz"}, - {"type": "turn.started"}, - { - "type": "item.started", - "item": {"id": "msg-1", "type": "agent_message", "text": "Hi"}, - }, - { - "type": "item.completed", - "item": {"id": "msg-1", "type": "agent_message", "text": "Hi there!"}, - }, - { - "type": "turn.completed", - "usage": {"input_tokens": 8, "output_tokens": 4, "total_tokens": 12}, - }, -] - - -async def _fake_event_stream(): - """Async iterator of pre-recorded codex event JSON lines (no subprocess).""" - for evt in SAMPLE_EVENTS: - yield json.dumps(evt) - - -class TestOfflineCodexHandler: - """Unit tests that run without a real codex CLI or network.""" - - @pytest.mark.asyncio - async def test_usage_populated_after_stream_exhausted(self): - """CodexTurn.usage() returns non-None tokens after stream is exhausted.""" - from agentex.lib.adk import CodexTurn - - turn = CodexTurn(events=_fake_event_stream(), model="o4-mini") - - collected = [e async for e in turn.events] - - usage = turn.usage() - assert usage.input_tokens == 8 - assert usage.output_tokens == 4 - assert usage.model == "o4-mini" - - @pytest.mark.asyncio - async def test_auto_send_turn_drives_unified_surface(self): - """auto_send_turn returns a TurnResult with the final text.""" - from agentex.lib.adk import CodexTurn - from agentex.lib.core.harness import UnifiedEmitter - from agentex.types.task_message import TaskMessage - from agentex.types.text_content import TextContent - - turn = CodexTurn(events=_fake_event_stream(), model="o4-mini") - - real_task_msg = TaskMessage( - id="msg-fake", - task_id="t", - content=TextContent(type="text", author="agent", content=""), - ) - - fake_streaming = MagicMock() - fake_ctx = AsyncMock() - fake_ctx.__aenter__ = AsyncMock(return_value=fake_ctx) - fake_ctx.__aexit__ = AsyncMock(return_value=False) - fake_ctx.stream_update = AsyncMock(return_value=MagicMock()) - fake_ctx.close = AsyncMock() - fake_ctx.task_message = real_task_msg - fake_streaming.streaming_task_message_context = MagicMock(return_value=fake_ctx) - - emitter = UnifiedEmitter( - task_id="t", - trace_id=None, - parent_span_id=None, - streaming=fake_streaming, - ) - - result = await emitter.auto_send_turn(turn) - assert result is not None - - @pytest.mark.asyncio - async def test_session_id_captured_after_stream(self): - """CodexTurn._result captures the session_id from thread.started.""" - from agentex.lib.adk import CodexTurn - - turn = CodexTurn(events=_fake_event_stream(), model="o4-mini") - _ = [e async for e in turn.events] - - assert turn._result is not None - assert turn._result["session_id"] == "thread-xyz" - - @pytest.mark.asyncio - async def test_yield_turn_is_passthrough(self): - """yield_turn mode also works with CodexTurn (no streaming infra needed).""" - from agentex.lib.adk import CodexTurn - from agentex.lib.core.harness import UnifiedEmitter - - turn = CodexTurn(events=_fake_event_stream(), model="o4-mini") - emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) - - events = [e async for e in emitter.yield_turn(turn)] - assert len(events) > 0 - - -# --------------------------------------------------------------------------- -# Live tests -# --------------------------------------------------------------------------- - -LIVE = os.environ.get("CODEX_LIVE_TESTS", "") == "1" -AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") -AGENT_NAME = os.environ.get("AGENT_NAME", "ab140-codex") - - -@pytest.mark.skipif( - not LIVE, - reason="Set CODEX_LIVE_TESTS=1 and ensure codex CLI + OPENAI_API_KEY are available", -) -class TestLiveCodexAgent: - """End-to-end tests that require the real codex CLI and a running Agentex server.""" - - @pytest.fixture - def client(self): - from agentex import Agentex - - return Agentex(base_url=AGENTEX_API_BASE_URL) - - @pytest.fixture - def agent_id(self, client): - for agent in client.agents.list(): - if agent.name == AGENT_NAME: - return agent.id - raise ValueError(f"Agent {AGENT_NAME!r} not found.") - - def test_send_simple_message(self, client, agent_id: str): - """Async agents process events out of band, so create a task, send an - event, and poll the task's messages for the agent's response.""" - import time - import uuid - - from agentex.types import TextContentParam - from agentex.types.agent_rpc_params import ParamsSendEventRequest, ParamsCreateTaskRequest - - task = client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)).result - assert task is not None - - client.agents.send_event( - agent_id=agent_id, - params=ParamsSendEventRequest( - task_id=task.id, - content=TextContentParam( - author="user", - content="What is 3+3? Reply with just the number.", - type="text", - ), - ), - ) - - deadline = time.monotonic() + 60 - while time.monotonic() < deadline: - msgs = client.messages.list(task_id=task.id) - agent_msgs = [m for m in msgs if getattr(m.content, "author", None) == "agent"] - if agent_msgs: - assert len(agent_msgs) >= 1 - return - time.sleep(2) - - raise AssertionError("No agent response received within 60 s") diff --git a/examples/tutorials/10_async/10_temporal/000_hello_acp/.dockerignore b/examples/tutorials/10_async/10_temporal/000_hello_acp/.dockerignore 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 e45ecd897..000000000 --- a/examples/tutorials/10_async/10_temporal/000_hello_acp/project/workflow.py +++ /dev/null @@ -1,79 +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. Gate this one-time prologue - # on is_continued_run(): run_until_complete below recycles the workflow via - # continue-as-new, which re-enters on_task_create from the top โ€” without this - # guard the "you should only see this once" welcome would re-fire on every - # recycle. Original run -> emit; continued (recycled) run -> skip. - if not self.is_continued_run(): - await adk.messages.create( - task_id=params.task.id, - content=TextContent( - author="agent", - content=f"Hello! I've received your task. Normally you can do some state initialization here, or just pass and do nothing until you get your first event. For now I'm just acknowledging that I've received a task with the following params:\n\n{json.dumps(params.params, indent=2)}.\n\nYou should only see this message once, when the task is created. All subsequent events will be handled by the `on_task_event_send` handler.", - ), - ) - - # 2. Keep the workflow open to field events. We use run_until_complete - # instead of a bare wait_condition: it still waits indefinitely, but also - # recycles the Temporal event history via continue-as-new before it hits the - # ~50k-event / 50MB limit, so this chat can stay open forever. Adopting - # run_until_complete IS the opt-in โ€” agents that keep the old wait_condition - # never recycle. This agent keeps no cross-turn state, so nothing needs - # restoring across a recycle and `params` is the only carry-forward. (Agents - # that DO keep state restore it at the top of @workflow.run on a recycled - # run โ€” framework-specific, landing per-integration in follow-up PRs.) - await self.run_until_complete(params, is_complete=lambda: self._complete_task) diff --git a/examples/tutorials/10_async/10_temporal/000_hello_acp/pyproject.toml b/examples/tutorials/10_async/10_temporal/000_hello_acp/pyproject.toml 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 f8c3e1aa8..000000000 --- a/examples/tutorials/10_async/10_temporal/010_agent_chat/tests/test_agent.py +++ /dev/null @@ -1,277 +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 - ): - # A turn emits several messages (user echo, reasoning, agent text), - # each ending in "full" or "done"; consume until the text reply lands. - msg_type = event.get("type") - if msg_type == "full": - parent_task_message = StreamTaskMessageFull.model_validate(event).parent_task_message - elif msg_type == "done": - parent_task_message = StreamTaskMessageDone.model_validate(event).parent_task_message - else: - continue - - if parent_task_message and parent_task_message.id: - finished_message = await client.messages.retrieve(parent_task_message.id) - content = finished_message.content - if content and content.type == "text" and content.author == "user": - user_message_found = True - elif content and content.type == "text" and content.author == "agent": - agent_response_found = True - elif content and content.type == "reasoning": - reasoning_found = True - - # Stop once both the user echo and the agent's text reply are seen. - if user_message_found and agent_response_found: - break - - stream_task = asyncio.create_task(stream_messages()) - event_content = TextContentParam(type="text", author="user", content=user_message) - await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) - await stream_task - - assert user_message_found, "User message not found in stream" - assert agent_response_found, "Agent response not found in stream" - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/10_temporal/020_state_machine/.dockerignore b/examples/tutorials/10_async/10_temporal/020_state_machine/.dockerignore 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 66466693b..000000000 --- a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# Temporal Pydantic AI Agent - -A minimal **Temporal-backed** Pydantic AI agent that drives the **unified -harness surface** (`UnifiedEmitter.auto_send_turn` + `PydanticAITurn`) from -inside the model activity's `event_stream_handler`. - -## Why this agent exists - -This agent calls `emitter.auto_send_turn(...)` **explicitly** inside -the `event_stream_handler`, making the unified-surface wiring visible and giving -the temporal channel direct coverage. - -## How it wires the unified surface - -In `project/agent.py`, the `event_stream_handler` runs inside the model activity -and constructs a `UnifiedEmitter` from `RunContext.deps`: - -```python -async def event_handler(run_context, events): - emitter = UnifiedEmitter( - task_id=run_context.deps.task_id, - trace_id=run_context.deps.task_id, - parent_span_id=run_context.deps.parent_span_id, - ) - turn = PydanticAITurn(events, model=MODEL_NAME, coalesce_tool_requests=True) - await emitter.auto_send_turn(turn) -``` - -- The handler runs inside a Temporal activity, so it can freely make - non-deterministic Redis + tracing writes. -- `coalesce_tool_requests=True` is required on the auto_send path until - AGX1-377 lands. -- `deps` (set by `project/workflow.py`) threads the `task_id` and the per-turn - `parent_span_id` into the handler so tool spans nest under the workflow's turn - span. - -## Structure - -- `project/acp.py` โ€” thin ACP server; FastACP auto-wires HTTP routes to the - workflow when `TemporalACPConfig` is used. -- `project/agent.py` โ€” base `Agent` + `TemporalAgent` + the unified-surface - `event_stream_handler`. -- `project/workflow.py` โ€” durable workflow; each turn delegates to - `temporal_agent.run(...)`. -- `project/run_worker.py` โ€” Temporal worker entry point. -- `project/tools.py` โ€” async `get_weather(city)` returning a constant. -- `tests/test_agent.py` โ€” live integration test (requires Temporal + Redis + - ACP server + worker). - -## Tools - -- `get_weather(city: str) -> str` (async): returns a fixed "sunny and 72ยฐF" - string. Each tool call becomes its own Temporal activity. - -## Offline coverage - -Offline integration tests for the same wiring (pydantic-ai `TestModel` + fake -streaming/tracing, no Temporal server) live in the SDK repo under -`tests/lib/core/harness/` (the pydantic-ai temporal suite). diff --git a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/manifest.yaml b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/manifest.yaml deleted file mode 100644 index 7ca454b05..000000000 --- a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/manifest.yaml +++ /dev/null @@ -1,62 +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 harness test agent using the unified emitter surface - - temporal: - enabled: true - workflows: - - name: at110-pydantic-ai - queue_name: at110_pydantic_ai_queue - - credentials: - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - - env_var_name: OPENAI_API_KEY - secret_name: openai-api-key - secret_key: api-key - - env_var_name: SGP_API_KEY - secret_name: sgp-api-key - secret_key: api-key - - env_var_name: SGP_ACCOUNT_ID - secret_name: sgp-account-id - secret_key: account-id - - env_var_name: SGP_CLIENT_BASE_URL - secret_name: sgp-client-base-url - secret_key: url - -deployment: - image: - repository: "" - tag: "latest" - - global: - agent: - name: "at110-pydantic-ai" - description: "A Temporal-backed Pydantic AI harness test agent using the unified emitter surface" - replicaCount: 1 - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" diff --git a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/__init__.py b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/__init__.py 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 c142dcf70..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 harness Pydantic AI test agent. - -This file is intentionally thin. When ``acp_type="async"`` is combined with -``TemporalACPConfig(type="temporal", ...)``, FastACP auto-wires: - - HTTP task/create โ†’ @workflow.run on the workflow class - HTTP task/event/send โ†’ @workflow.signal(SignalName.RECEIVE_EVENT) - HTTP task/cancel โ†’ workflow cancellation via the Temporal client - -so we don't define any handlers here. The actual agent code lives in -``project/workflow.py`` and is executed by the Temporal worker -(``project/run_worker.py``), not by this HTTP process. -""" - -from __future__ import annotations - -import os - -from dotenv import load_dotenv - -load_dotenv() - -from pydantic_ai.durable_exec.temporal import PydanticAIPlugin - -from agentex.lib.types.fastacp import TemporalACPConfig -from agentex.lib.sdk.fastacp.fastacp import FastACP - -acp = FastACP.create( - acp_type="async", - config=TemporalACPConfig( - type="temporal", - temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), - plugins=[PydanticAIPlugin()], - ), -) diff --git a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/agent.py b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/agent.py deleted file mode 100644 index 4e59688ce..000000000 --- a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/agent.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Pydantic AI agent definition for the Temporal harness test agent. - -This module constructs the base ``pydantic_ai.Agent`` once at import time, -registers tools on it, and wraps it in ``TemporalAgent`` from -``pydantic_ai.durable_exec.temporal``. - -The ``TemporalAgent`` wrapper makes every model call and every tool call run as -a Temporal activity automatically. The workflow stays deterministic; the -non-deterministic work (LLM HTTP calls, tool execution) moves into recorded -activities. - -Streaming back to Agentex happens via ``event_stream_handler``, which receives -Pydantic AI ``AgentStreamEvent``s from inside the model activity and forwards -them through the UNIFIED HARNESS SURFACE (``UnifiedEmitter.auto_send_turn`` + -``PydanticAITurn``) โ€” called directly rather than via ``stream_pydantic_ai_events``. -The ``task_id`` and per-turn ``parent_span_id`` are threaded into the handler -via ``deps``. -""" - -from __future__ import annotations - -from datetime import datetime -from collections.abc import AsyncIterable - -from pydantic import BaseModel -from pydantic_ai import Agent, RunContext -from pydantic_ai.messages import AgentStreamEvent -from pydantic_ai.durable_exec.temporal import TemporalAgent - -from project.tools import get_weather -from agentex.lib.core.harness import UnifiedEmitter -from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn - -__all__ = ["TaskDeps", "temporal_agent", "base_agent", "MODEL_NAME"] - -MODEL_NAME = "openai:gpt-4o-mini" -SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. - -Current date and time: {timestamp} - -Guidelines: -- Be concise and helpful -- Use tools when they would help answer the user's question -- If you're unsure, ask clarifying questions -- Always provide accurate information -""" - - -class TaskDeps(BaseModel): - """Per-run dependencies passed into the agent via ``deps=``. - - Pydantic AI's ``RunContext.deps`` is the canonical place to thread - request-scoped data (like the Agentex task_id) into tools and event - handlers โ€” including code that runs inside Temporal activities. - """ - - task_id: str - # When set, the event handler nests per-tool-call spans under this span. - # Typically the ID of the per-turn span opened by the workflow. - parent_span_id: str | None = None - - -def _build_base_agent() -> Agent[TaskDeps, str]: - """Build the underlying Pydantic AI agent with tools registered. - - Tools must be registered BEFORE the agent is wrapped in TemporalAgent; - changes to tool registration after wrapping are not reflected. - """ - agent: Agent[TaskDeps, str] = Agent( - MODEL_NAME, - deps_type=TaskDeps, - system_prompt=SYSTEM_PROMPT.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")), - ) - agent.tool_plain(get_weather) - return agent - - -async def event_handler( - run_context: RunContext[TaskDeps], - events: AsyncIterable[AgentStreamEvent], -) -> None: - """Stream Pydantic AI events to Agentex via the unified surface. - - Pydantic AI calls this with the live event stream as soon as the model - activity begins emitting parts. Because the handler runs inside the activity - (not the workflow), it can freely make non-deterministic Redis + tracing - writes. - - The UnifiedEmitter is constructed from ``deps`` (task_id + parent_span_id), - so tool spans nest under the workflow's per-turn span and messages auto-send - to the task stream. The auto_send path delivers streamed tool requests - natively, so no coalescing workaround is needed. - """ - emitter = UnifiedEmitter( - task_id=run_context.deps.task_id, - trace_id=run_context.deps.task_id, - parent_span_id=run_context.deps.parent_span_id, - ) - turn = PydanticAITurn(events, model=MODEL_NAME) - await emitter.auto_send_turn(turn) - - -# Construct the durable agent at module load time so that the PydanticAIPlugin -# can auto-discover its activities via the workflow's ``__pydantic_ai_agents__`` -# attribute. -base_agent = _build_base_agent() -temporal_agent: TemporalAgent[TaskDeps, str] = TemporalAgent( - base_agent, - name="pydantic_ai_agent", - event_stream_handler=event_handler, -) diff --git a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/run_worker.py b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/run_worker.py deleted file mode 100644 index 4b4d43d19..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 harness Pydantic AI test agent. - -Run as a separate long-lived process alongside the ACP HTTP server. The worker -polls Temporal for workflow + activity tasks and executes them. - -The ``PydanticAIPlugin`` reads ``__pydantic_ai_agents__`` off the workflow class -and registers every model/tool activity the TemporalAgent needs โ€” so we don't -have to enumerate activities by hand here. -""" - -import asyncio - -from pydantic_ai.durable_exec.temporal import PydanticAIPlugin - -from project.workflow import HarnessPydanticAiWorkflow -from agentex.lib.utils.debug import setup_debug_if_enabled -from agentex.lib.utils.logging import make_logger -from agentex.lib.environment_variables import EnvironmentVariables -from agentex.lib.core.temporal.activities import get_all_activities -from agentex.lib.core.temporal.workers.worker import AgentexWorker - -environment_variables = EnvironmentVariables.refresh() -logger = make_logger(__name__) - - -async def main(): - setup_debug_if_enabled() - - task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE - if task_queue_name is None: - raise ValueError("WORKFLOW_TASK_QUEUE is not set") - - # get_all_activities() returns the built-in Agentex activities (state, - # messages, streaming, tracing). Pydantic AI's TemporalAgent activities are - # auto-registered by PydanticAIPlugin via __pydantic_ai_agents__. - worker = AgentexWorker( - task_queue=task_queue_name, - plugins=[PydanticAIPlugin()], - ) - - await worker.run( - activities=get_all_activities(), - workflow=HarnessPydanticAiWorkflow, - ) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/tools.py b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/tools.py deleted file mode 100644 index bbd6c5200..000000000 --- a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/tools.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Tool definitions for the Temporal harness Pydantic AI agent. - -These functions are registered on the base Pydantic AI agent. When the agent -is wrapped in ``TemporalAgent``, each tool call becomes its own Temporal -activity automatically โ€” independently retryable and observable. - -Tools must be ``async`` because Pydantic AI's Temporal integration requires -it: non-async tools would run in threads, which is non-deterministic and -unsafe for Temporal replay. -""" - -from __future__ import annotations - - -async def get_weather(city: str) -> str: - """Get the current weather for a city. - - Args: - city: The name of the city to get weather for. - - Returns: - A string describing the weather conditions. - """ - return f"The weather in {city} is sunny and 72ยฐF" diff --git a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/workflow.py b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/workflow.py deleted file mode 100644 index 9a01be7de..000000000 --- a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/project/workflow.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Temporal workflow for the harness Pydantic AI test agent. - -The workflow holds task state durably across crashes. Its signal handler -delegates the actual agent run to ``temporal_agent.run(...)`` โ€” which internally -schedules model and tool activities, each independently durable. The -``event_stream_handler`` registered on ``temporal_agent`` (see project.agent) -pushes streaming deltas through the unified harness surface while the model -activity runs. - -Multi-turn memory is kept on the workflow instance itself -(``self._message_history``). Temporal's workflow state is already durable and -replay-safe, so unlike the async-base agent we don't need an external -``adk.state`` round-trip. -""" - -from __future__ import annotations - -import os -import json -from typing import TYPE_CHECKING - -from temporalio import workflow - -from agentex.lib import adk -from project.agent import TaskDeps, temporal_agent -from agentex.lib.types.acp import SendEventParams, CreateTaskParams -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.types.text_content import TextContent -from agentex.lib.environment_variables import EnvironmentVariables -from agentex.lib.core.temporal.types.workflow import SignalName -from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow -from agentex.lib.core.tracing.tracing_processor_manager import ( - add_tracing_processor_config, -) - -if TYPE_CHECKING: - from pydantic_ai.messages import ModelMessage - -add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SGP_API_KEY", ""), - sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - ) -) - -environment_variables = EnvironmentVariables.refresh() - -if environment_variables.WORKFLOW_NAME is None: - raise ValueError("Environment variable WORKFLOW_NAME is not set") -if environment_variables.AGENT_NAME is None: - raise ValueError("Environment variable AGENT_NAME is not set") - -logger = make_logger(__name__) - - -@workflow.defn(name=environment_variables.WORKFLOW_NAME) -class HarnessPydanticAiWorkflow(BaseWorkflow): - """Long-running Temporal workflow that delegates each turn to a Pydantic AI TemporalAgent. - - The ``__pydantic_ai_agents__`` attribute is the marker the - ``PydanticAIPlugin`` looks for at worker startup: it pulls - ``temporal_agent.temporal_activities`` off this list and registers them on - the worker automatically โ€” so we don't have to list activities by hand in - ``run_worker.py``. - """ - - __pydantic_ai_agents__ = [temporal_agent] - - def __init__(self): - super().__init__(display_name=environment_variables.AGENT_NAME) - self._complete_task = False - self._turn_number = 0 - # Conversation history accumulated across turns. Each entry is a - # pydantic-ai ``ModelMessage``. Temporal replays the activity that - # produced these messages, so the list is rebuilt deterministically if - # the workflow ever recovers from a crash. - self._message_history: list["ModelMessage"] = [] - - @workflow.signal(name=SignalName.RECEIVE_EVENT) - async def on_task_event_send(self, params: SendEventParams) -> None: - """Handle a new user message: echo it, then run the agent durably.""" - logger.info(f"Received task event: {params.task.id}") - self._turn_number += 1 - - # Echo the user's message so it shows up in the UI as a chat bubble. - await adk.messages.create(task_id=params.task.id, content=params.event.content) - - async with adk.tracing.span( - trace_id=params.task.id, - task_id=params.task.id, - name=f"Turn {self._turn_number}", - input={"message": params.event.content.content}, - ) as span: - # temporal_agent.run() schedules a model activity, per-tool - # activities, and the event_stream_handler activity (which pushes - # deltas through the unified surface). Passing ``message_history`` - # makes the run remember prior turns. - result = await temporal_agent.run( - params.event.content.content, - message_history=self._message_history, - deps=TaskDeps( - task_id=params.task.id, - parent_span_id=span.id if span else None, - ), - ) - # Persist the new full history (user + assistant + any tool rounds) - # so the next turn picks up from here. - self._message_history = list(result.all_messages()) - if span: - span.output = {"final_output": result.output} - - @workflow.run - async def on_task_create(self, params: CreateTaskParams) -> str: - """Workflow entry point โ€” keep the conversation alive for incoming signals.""" - logger.info(f"Task created: {params.task.id}") - - await adk.messages.create( - task_id=params.task.id, - content=TextContent( - author="agent", - content=( - f"Task initialized with params:\n{json.dumps(params.params, indent=2)}\n" - f"Send me a message and I'll respond using a Pydantic AI agent backed by Temporal." - ), - ), - ) - - await workflow.wait_condition(lambda: self._complete_task, timeout=None) - return "Task completed" - - @workflow.signal - async def complete_task_signal(self) -> None: - """Graceful workflow shutdown signal.""" - logger.info("Received complete_task signal") - self._complete_task = True diff --git a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/pyproject.toml b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/pyproject.toml deleted file mode 100644 index 2f308f2a1..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 harness test agent using the unified emitter surface" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "agentex-sdk", - "scale-gp", - "temporalio>=1.18.2", - "pydantic-ai-slim[openai]>=1.0,<2", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "httpx", - "black", - "isort", - "flake8", - "debugpy>=1.8.15", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] - -[tool.black] -line-length = 88 -target-version = ['py312'] - -[tool.isort] -profile = "black" -line_length = 88 diff --git a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/tests/test_agent.py deleted file mode 100644 index 974cddcc0..000000000 --- a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/tests/test_agent.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Live tests for the Temporal Pydantic AI agent. - -These tests require a running agent (Temporal + Redis + ACP server + worker) and -exercise the unified-surface event_stream_handler end-to-end over the wire. - -Offline coverage of the same wiring (TestModel + fake streaming/tracing) lives -in the SDK repo under ``tests/lib/core/harness/`` (the pydantic-ai temporal suite). - -To run these tests: -1. Make sure the agent is running (worker + ACP server) -2. Set AGENTEX_API_BASE_URL if not using the default -3. Run: pytest tests/test_agent.py -v -""" - -import os -import uuid - -import pytest -import pytest_asyncio -from test_utils.async_utils import poll_messages, send_event_and_poll_yielding - -from agentex import AsyncAgentex -from agentex.types.task_message import TaskMessage -from agentex.types.agent_rpc_params import ParamsCreateTaskRequest - -AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") -AGENT_NAME = os.environ.get("AGENT_NAME", "at110-pydantic-ai") - - -@pytest_asyncio.fixture -async def client(): - client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) - yield client - await client.close() - - -@pytest.fixture -def agent_name(): - return AGENT_NAME - - -@pytest_asyncio.fixture -async def agent_id(client, agent_name): - agents = await client.agents.list() - for agent in agents: - if agent.name == agent_name: - return agent.id - raise ValueError(f"Agent with name {agent_name} not found.") - - -class TestNonStreamingEvents: - """Test that the Temporal-backed harness agent responds and uses tools.""" - - @pytest.mark.asyncio - async def test_send_event_and_poll(self, client: AsyncAgentex, agent_id: str): - """Drive a full turn: create task, send a weather question, verify tool round-trip.""" - task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) - task = task_response.result - assert task is not None - - # Wait for the welcome message from on_task_create - task_creation_found = False - async for message in poll_messages( - client=client, - task_id=task.id, - timeout=30, - sleep_interval=1.0, - ): - assert isinstance(message, TaskMessage) - if message.content and message.content.type == "text" and message.content.author == "agent": - task_creation_found = True - break - assert task_creation_found, "Task creation welcome message not found" - - # Ask about weather โ€” the agent should call get_weather - seen_tool_request = False - seen_tool_response = False - final_message = None - async for message in send_event_and_poll_yielding( - client=client, - agent_id=agent_id, - task_id=task.id, - user_message="What is the weather in San Francisco?", - timeout=60, - sleep_interval=1.0, - ): - assert isinstance(message, TaskMessage) - - if message.content and message.content.type == "tool_request": - seen_tool_request = True - if message.content and message.content.type == "tool_response": - seen_tool_response = True - if final_message and getattr(final_message, "streaming_status", None) == "DONE": - break - - if message.content and message.content.type == "text" and message.content.author == "agent": - final_message = message - content_length = len(getattr(message.content, "content", "") or "") - if message.streaming_status == "DONE" and content_length > 0: - if not seen_tool_request or seen_tool_response: - break - - assert seen_tool_request, "Expected a tool_request (agent calling get_weather)" - assert seen_tool_response, "Expected a tool_response (get_weather result)" - assert final_message is not None, "Expected a final agent text message" - final_text = getattr(final_message.content, "content", None) if final_message.content else None - assert isinstance(final_text, str) and len(final_text) > 0 - # The get_weather tool always returns "72ยฐF" โ€” the response should mention it. - assert "72" in final_text, "Expected weather response to mention 72ยฐF" - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/.dockerignore b/examples/tutorials/10_async/10_temporal/120_openai_agents/.dockerignore deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents/.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/Dockerfile b/examples/tutorials/10_async/10_temporal/120_openai_agents/Dockerfile deleted file mode 100644 index 700f56cea..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents/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/120_openai_agents/pyproject.toml /app/120_openai_agents/pyproject.toml -COPY 10_async/10_temporal/120_openai_agents/README.md /app/120_openai_agents/README.md - -WORKDIR /app/120_openai_agents - -COPY 10_async/10_temporal/120_openai_agents/project /app/120_openai_agents/project -COPY 10_async/10_temporal/120_openai_agents/tests /app/120_openai_agents/tests -COPY test_utils /app/test_utils - -RUN uv pip install --system .[dev] - -ENV PYTHONPATH=/app - -ENV AGENT_NAME=at120-openai-agents - -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] - -# When we deploy the worker, we will replace the CMD with the following -# CMD ["python", "-m", "run_worker"] diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/README.md b/examples/tutorials/10_async/10_temporal/120_openai_agents/README.md deleted file mode 100644 index 4db26d0a1..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# Temporal OpenAI Agents on the unified harness surface - -A Temporal-backed Agentex agent that runs the OpenAI Agents SDK and delivers its -output through the **unified harness surface**. - -## What this demonstrates - -LLM calls are non-deterministic, so they can't run directly in a Temporal -workflow. This tutorial keeps the workflow (`project/workflow.py`) -deterministic and delegates each turn to a custom activity -(`project/activities.py`). The activity uses the SAME `OpenAITurn` adapter as -the sync (`050_openai_agents`) and async (`120_openai_agents`) variants, and -delivers via `UnifiedEmitter.auto_send_turn` โ€” which is designed to run inside -an activity (it writes streaming side effects to Redis and returns the final -text + usage). - -```python -# inside the activity: -result = Runner.run_streamed(starting_agent=agent, input=user_message) -turn = OpenAITurn(result=result, model="gpt-4o") -emitter = UnifiedEmitter(task_id=task_id, trace_id=trace_id, parent_span_id=parent_span_id) -turn_result = await emitter.auto_send_turn(turn) -return turn_result.final_text -``` - -## Run it - -```bash -agentex agents run --manifest manifest.yaml -``` - -This starts both the ACP HTTP server and the Temporal worker. - -## Test it - -The offline test exercises the activity's delivery path with an injected fake -streaming backend (no server, Temporal, Redis, or API key required): - -```bash -pytest tests/test_agent.py -v -``` diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/environments.yaml b/examples/tutorials/10_async/10_temporal/120_openai_agents/environments.yaml deleted file mode 100644 index f90511911..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents/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/manifest.yaml b/examples/tutorials/10_async/10_temporal/120_openai_agents/manifest.yaml deleted file mode 100644 index 4b59db442..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents/manifest.yaml +++ /dev/null @@ -1,62 +0,0 @@ -build: - context: - root: ../../../ - include_paths: - - 10_async/10_temporal/120_openai_agents - - test_utils - dockerfile: 10_async/10_temporal/120_openai_agents/Dockerfile - dockerignore: 10_async/10_temporal/120_openai_agents/.dockerignore - -local_development: - agent: - port: 8000 - host_address: host.docker.internal - paths: - acp: project/acp.py - worker: project/run_worker.py - -agent: - acp_type: async - name: at120-openai-agents - description: A Temporal-backed OpenAI Agents SDK agent on the unified harness surface - - temporal: - enabled: true - workflows: - - name: at120-openai-agents - queue_name: at120_openai_agents_queue - - credentials: - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - - env_var_name: OPENAI_API_KEY - secret_name: openai-api-key - secret_key: api-key - - env_var_name: SGP_API_KEY - secret_name: sgp-api-key - secret_key: api-key - - env_var_name: SGP_ACCOUNT_ID - secret_name: sgp-account-id - secret_key: account-id - - env_var_name: SGP_CLIENT_BASE_URL - secret_name: sgp-client-base-url - secret_key: url - -deployment: - image: - repository: "" - tag: "latest" - - global: - agent: - name: "at120-openai-agents" - description: "A Temporal-backed OpenAI Agents SDK agent on the unified harness surface" - replicaCount: 1 - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/project/__init__.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/project/acp.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/acp.py deleted file mode 100644 index 6076835ba..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents/project/acp.py +++ /dev/null @@ -1,33 +0,0 @@ -"""ACP server for the Temporal OpenAI Agents harness tutorial. - -Thin by design: with ``acp_type="async"`` + ``TemporalACPConfig``, FastACP -auto-wires task/create, task/event/send, and task/cancel onto the workflow. -The agent logic lives in ``project/workflow.py`` (deterministic) and -``project/activities.py`` (the harness-backed LLM run), executed by the worker -in ``project/run_worker.py``. -""" - -from __future__ import annotations - -import os - -from dotenv import load_dotenv - -load_dotenv() - -from agentex.lib.types.fastacp import TemporalACPConfig -from agentex.lib.sdk.fastacp.fastacp import FastACP - -# LiteLLM proxy auth: copy LITELLM_API_KEY to OPENAI_API_KEY for OpenAI client -# compatibility, so the same example works behind the Scale LiteLLM gateway. -_litellm_key = os.environ.get("LITELLM_API_KEY") -if _litellm_key and not os.environ.get("OPENAI_API_KEY"): - os.environ["OPENAI_API_KEY"] = _litellm_key - -acp = FastACP.create( - acp_type="async", - config=TemporalACPConfig( - type="temporal", - temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), - ), -) diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/project/activities.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/activities.py deleted file mode 100644 index 72c92d617..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents/project/activities.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Custom Temporal activity that runs the OpenAI agent on the harness surface. - -LLM calls are non-deterministic, so they must run inside a Temporal activity -rather than directly in the workflow. This activity runs the OpenAI Agents SDK -via ``Runner.run_streamed``, wraps the result in an ``OpenAITurn``, and pushes -the canonical stream to the task stream via ``UnifiedEmitter.auto_send_turn``. - -``auto_send`` (which backs ``auto_send_turn``) is explicitly designed to be -called from inside an activity: it writes streaming side effects to Redis and -returns the accumulated final text + normalized usage. -""" - -from __future__ import annotations - -from typing import Any -from datetime import datetime - -from agents import Runner -from pydantic import BaseModel -from temporalio import activity - -from project.agent import MODEL_NAME, create_agent -from agentex.lib.utils.logging import make_logger -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.lib.adk.providers._modules.openai_turn import OpenAITurn - -logger = make_logger(__name__) - -RUN_AGENT_ACTIVITY = "run_openai_agent" - - -class RunHarnessAgentParams(BaseModel): - """Parameters for the harness agent activity.""" - - task_id: str - user_message: str - # Prior conversation as OpenAI Agents SDK input items, so the agent sees the - # full history (not just the latest message) on every turn. - input_list: list[Any] = [] - trace_id: str | None = None - parent_span_id: str | None = None - # Deterministic turn timestamp from workflow.now(); forwarded to - # auto_send_turn so retried activities re-emit messages with stable - # timestamps instead of new server-side ones (which could reorder turns). - created_at: datetime | None = None - - -class RunHarnessAgentResult(BaseModel): - """Result of one harness turn.""" - - final_text: str - # Updated conversation (prior history + this turn) to carry into the next turn. - input_list: list[Any] - - -class HarnessActivities: - """Hosts the harness-backed OpenAI agent activity.""" - - @activity.defn(name=RUN_AGENT_ACTIVITY) - async def run_openai_agent(self, params: RunHarnessAgentParams) -> RunHarnessAgentResult: - """Run the agent for one turn and auto-send its output. - - Threads the running conversation through ``input_list`` so multi-turn - chats retain memory: prior history + the new user message go in, and the - updated conversation comes back out via ``result.to_input_list()``. - """ - logger.info(f"Running harness OpenAI agent for task {params.task_id}") - - agent = create_agent() - input_list: list[Any] = [*params.input_list, {"role": "user", "content": params.user_message}] - result = Runner.run_streamed(starting_agent=agent, input=input_list) - turn = OpenAITurn(result=result, model=MODEL_NAME) - emitter = UnifiedEmitter( - task_id=params.task_id, - trace_id=params.trace_id, - parent_span_id=params.parent_span_id, - ) - turn_result = await emitter.auto_send_turn(turn, created_at=params.created_at) - # to_input_list() is valid now: auto_send_turn has exhausted the stream. - return RunHarnessAgentResult(final_text=turn_result.final_text, input_list=result.to_input_list()) diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/project/agent.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/agent.py deleted file mode 100644 index 385a80b69..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents/project/agent.py +++ /dev/null @@ -1,44 +0,0 @@ -"""OpenAI Agents SDK agent definition for the Temporal harness tutorial. - -Same agent shape as the sync (060) and async (130) variants. Here the agent is -built and run inside a Temporal activity (see ``project.activities``); the -workflow stays deterministic and delegates the non-deterministic LLM run to that -activity, which delivers the turn via the unified harness surface. -""" - -from __future__ import annotations - -from datetime import datetime - -from agents import Agent, function_tool, set_tracing_disabled - -from project.tools import get_weather - -set_tracing_disabled(True) - -MODEL_NAME = "gpt-4o" -INSTRUCTIONS = """You are a helpful AI assistant with access to tools. - -Current date and time: {timestamp} - -Guidelines: -- Be concise and helpful -- Use the weather tool when the user asks about the weather -- Always report the real tool output back to the user -""" - - -@function_tool -def weather(city: str) -> str: - """Get the current weather for a city.""" - return get_weather(city) - - -def create_agent() -> Agent: - """Build and return the OpenAI Agents SDK agent with the weather tool.""" - return Agent( - name="Harness OpenAI Assistant", - model=MODEL_NAME, - instructions=INSTRUCTIONS.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")), - tools=[weather], - ) diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/project/run_worker.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/run_worker.py deleted file mode 100644 index b82ee0f50..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents/project/run_worker.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Temporal worker for the OpenAI Agents harness tutorial. - -Runs as a separate long-lived process alongside the ACP HTTP server. Registers -the built-in Agentex activities plus the custom harness agent activity -(``HarnessActivities.run_openai_agent``), and the workflow. -""" - -import asyncio - -from project.workflow import At140HarnessOpenaiWorkflow -from project.activities import HarnessActivities -from agentex.lib.utils.debug import setup_debug_if_enabled -from agentex.lib.utils.logging import make_logger -from agentex.lib.environment_variables import EnvironmentVariables -from agentex.lib.core.temporal.activities import get_all_activities -from agentex.lib.core.temporal.workers.worker import AgentexWorker - -environment_variables = EnvironmentVariables.refresh() -logger = make_logger(__name__) - - -async def main(): - setup_debug_if_enabled() - - task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE - if task_queue_name is None: - raise ValueError("WORKFLOW_TASK_QUEUE is not set") - - harness_activities = HarnessActivities() - all_activities = [ - harness_activities.run_openai_agent, - *get_all_activities(), - ] - - worker = AgentexWorker(task_queue=task_queue_name) - - await worker.run( - activities=all_activities, - workflow=At140HarnessOpenaiWorkflow, - ) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/project/tools.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/tools.py deleted file mode 100644 index d26f9b097..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents/project/tools.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Tool definitions for the Temporal OpenAI Agents harness tutorial.""" - -from __future__ import annotations - - -def get_weather(city: str) -> str: - """Get the current weather for a city. - - Args: - city: The name of the city to get weather for. - - Returns: - A string describing the weather conditions. - """ - return f"The weather in {city} is sunny and 72ยฐF" diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/project/workflow.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/workflow.py deleted file mode 100644 index 5cb8fb38b..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents/project/workflow.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Temporal workflow for the OpenAI Agents harness tutorial. - -The workflow stays deterministic: it echoes the user message and delegates the -non-deterministic LLM run to ``run_openai_agent`` (see -``project.activities``). That activity runs the OpenAI Agents SDK and delivers -the turn through the unified harness surface (``OpenAITurn`` + -``UnifiedEmitter.auto_send_turn``). -""" - -from __future__ import annotations - -import os -import json -from datetime import timedelta - -from temporalio import workflow -from temporalio.common import RetryPolicy - -from agentex.lib import adk -from project.activities import ( - RUN_AGENT_ACTIVITY, - RunHarnessAgentParams, - RunHarnessAgentResult, -) -from agentex.lib.types.acp import SendEventParams, CreateTaskParams -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.types.text_content import TextContent -from agentex.lib.environment_variables import EnvironmentVariables -from agentex.lib.core.temporal.types.workflow import SignalName -from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config - -add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SGP_API_KEY", ""), - sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - ) -) - -environment_variables = EnvironmentVariables.refresh() - -if environment_variables.WORKFLOW_NAME is None: - raise ValueError("Environment variable WORKFLOW_NAME is not set") -if environment_variables.AGENT_NAME is None: - raise ValueError("Environment variable AGENT_NAME is not set") - -logger = make_logger(__name__) - - -@workflow.defn(name=environment_variables.WORKFLOW_NAME) -class At140HarnessOpenaiWorkflow(BaseWorkflow): - """Long-running workflow that runs each turn through the harness activity.""" - - def __init__(self): - super().__init__(display_name=environment_variables.AGENT_NAME) - self._complete_task = False - self._turn_number = 0 - # Running conversation (OpenAI Agents SDK input items) so each turn sees - # the full history, not just the latest user message. - self._messages: list = [] - - @workflow.signal(name=SignalName.RECEIVE_EVENT) - async def on_task_event_send(self, params: SendEventParams) -> None: - """Handle a user message: echo it, then run the harness activity durably.""" - logger.info(f"Received task event: {params.task.id}") - self._turn_number += 1 - - # Echo the user's message so it shows up in the UI as a chat bubble. - await adk.messages.create(task_id=params.task.id, content=params.event.content) - - async with adk.tracing.span( - trace_id=params.task.id, - task_id=params.task.id, - name=f"Turn {self._turn_number}", - input={"message": params.event.content.content}, - ) as span: - turn_result = await workflow.execute_activity( - RUN_AGENT_ACTIVITY, - RunHarnessAgentParams( - task_id=params.task.id, - user_message=params.event.content.content, - input_list=self._messages, - trace_id=params.task.id, - parent_span_id=span.id if span else None, - # Deterministic timestamp under replay so a retried activity - # re-emits this turn's messages with stable ordering. - created_at=workflow.now(), - ), - start_to_close_timeout=timedelta(minutes=5), - retry_policy=RetryPolicy(maximum_attempts=3), - result_type=RunHarnessAgentResult, - ) - # Carry the updated conversation into the next turn. - self._messages = turn_result.input_list - if span: - span.output = {"final_output": turn_result.final_text} - - @workflow.run - async def on_task_create(self, params: CreateTaskParams) -> str: - """Workflow entry point โ€” keep the conversation alive for incoming signals.""" - logger.info(f"Task created: {params.task.id}") - - await adk.messages.create( - task_id=params.task.id, - content=TextContent( - author="agent", - content=( - f"Task initialized with params:\n{json.dumps(params.params, indent=2)}\n" - f"Send me a message and I'll respond using an OpenAI Agents SDK agent " - f"delivered through the unified harness surface." - ), - ), - ) - - await workflow.wait_condition(lambda: self._complete_task, timeout=None) - return "Task completed" - - @workflow.signal - async def complete_task_signal(self) -> None: - """Graceful workflow shutdown signal.""" - logger.info("Received complete_task signal") - self._complete_task = True diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/pyproject.toml b/examples/tutorials/10_async/10_temporal/120_openai_agents/pyproject.toml deleted file mode 100644 index e6c77fae3..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents/pyproject.toml +++ /dev/null @@ -1,38 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "at120-openai-agents" -version = "0.1.0" -description = "A Temporal-backed OpenAI Agents SDK agent on the unified harness surface" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "agentex-sdk", - "scale-gp", - "temporalio>=1.18.2", - "openai-agents", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "httpx", - "black", - "isort", - "flake8", - "debugpy>=1.8.15", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] - -[tool.black] -line-length = 88 -target-version = ['py312'] - -[tool.isort] -profile = "black" -line_length = 88 diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/tests/test_agent.py deleted file mode 100644 index dd043c44c..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents/tests/test_agent.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Offline test for the Temporal OpenAI Agents harness tutorial. - -This test does NOT require a running Agentex server, Temporal, Redis, or an -OpenAI API key. It verifies the delivery path the harness activity uses: an -``OpenAITurn`` built from an injected canonical stream, pushed through -``UnifiedEmitter.auto_send_turn`` with an injected fake streaming backend, -returns the accumulated final text (which the activity returns to the workflow). - -To run: ``pytest tests/test_agent.py -v`` -""" - -from __future__ import annotations - -import pytest - -from agentex.types.task_message import TaskMessage -from agentex.types.text_content import TextContent -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.types.task_message_delta import TextDelta -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.lib.adk.providers._modules.openai_turn import OpenAITurn - - -class _FakeCtx: - def __init__(self, initial_content): - self.task_message = TaskMessage(id="m-1", task_id="task-1", content=initial_content) - - async def __aenter__(self): - return self - - async def __aexit__(self, *a): - await self.close() - return False - - async def close(self): - pass - - async def stream_update(self, update): - return update - - -class _FakeStreaming: - def streaming_task_message_context(self, task_id, initial_content, **_kwargs): # noqa: ARG002 - return _FakeCtx(initial_content) - - -async def _canonical_stream(events): - for e in events: - yield e - - -@pytest.mark.asyncio -async def test_activity_delivery_returns_final_text(): - events = [ - StreamTaskMessageStart(type="start", index=0, content=TextContent(type="text", author="agent", content="")), - StreamTaskMessageDelta(type="delta", index=0, delta=TextDelta(type="text", text_delta="72")), - StreamTaskMessageDelta(type="delta", index=0, delta=TextDelta(type="text", text_delta="F")), - StreamTaskMessageDone(type="done", index=0), - ] - turn = OpenAITurn(stream=_canonical_stream(events), model="gpt-4o") - emitter = UnifiedEmitter( - task_id="task-1", - trace_id=None, - parent_span_id=None, - streaming=_FakeStreaming(), - ) - - result = await emitter.auto_send_turn(turn) - assert result.final_text == "72F" - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/.dockerignore b/examples/tutorials/10_async/10_temporal/130_langgraph/.dockerignore deleted file mode 100644 index c49489471..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 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 0820f56ab..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# Tutorial: Temporal LangGraph Agent - -This tutorial demonstrates how to build a **Temporal-backed** LangGraph agent on -AgentEx using the **unified harness surface**. The agent's LLM node runs as a -durable Temporal activity; the tools node runs inline in the workflow. - -## Key Concepts - -### Temporal + LangGraph - -The ``LangGraphPlugin`` from ``temporalio.contrib.langgraph`` turns annotated graph -nodes into Temporal activities or inline workflow callables: - -- `agent` node: `execute_in="activity"` (durable, retryable LLM call) -- `tools` node: `execute_in="workflow"` (inline, fast tool execution) - -### Message surfacing - -After each turn, ``emit_langgraph_messages`` converts the new LangGraph messages -(tool requests, tool responses, final text) into AgentEx ``TaskMessage`` objects -and posts them to the task's message stream. - -This is the Temporal-specific path. The non-Temporal async/sync channels use -``UnifiedEmitter.auto_send_turn`` / ``UnifiedEmitter.yield_turn`` with -``LangGraphTurn`` instead. - -## Files - -| File | Description | -|------|-------------| -| `project/acp.py` | ACP server (Temporal config, LangGraphPlugin) | -| `project/graph.py` | LangGraph graph (agent + tools nodes) | -| `project/workflow.py` | Temporal workflow (signal handlers, emit_langgraph_messages) | -| `project/run_worker.py` | Temporal worker runner | -| `project/tools.py` | Tool definitions (weather example) | -| `tests/test_agent.py` | Integration tests | -| `manifest.yaml` | Agent configuration (name: at130-langgraph) | - -## Running Locally - -```bash -agentex agents run -``` - -## Running Tests - -```bash -pytest tests/test_agent.py -v -``` diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/manifest.yaml b/examples/tutorials/10_async/10_temporal/130_langgraph/manifest.yaml deleted file mode 100644 index 534c8dd58..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/manifest.yaml +++ /dev/null @@ -1,59 +0,0 @@ -build: - context: - root: ../../../ - include_paths: - - 10_async/10_temporal/130_langgraph - - test_utils - dockerfile: 10_async/10_temporal/130_langgraph/Dockerfile - dockerignore: 10_async/10_temporal/130_langgraph/.dockerignore - -local_development: - agent: - port: 8000 - host_address: host.docker.internal - paths: - acp: project/acp.py - worker: project/run_worker.py - -agent: - acp_type: async - name: at130-langgraph - description: "A Temporal-backed LangGraph agent (harness variant) whose nodes run as Temporal activities" - - temporal: - enabled: true - workflows: - - name: at130-langgraph - queue_name: at130_langgraph_queue - - credentials: - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - # graph.py builds ChatOpenAI(model=MODEL_NAME); a deployed worker needs the - # model credential or the first activity call fails. - - env_var_name: OPENAI_API_KEY - secret_name: openai-api-key - secret_key: api-key - - env: {} - -deployment: - image: - repository: "" - tag: "latest" - - imagePullSecrets: [] - - global: - agent: - name: "at130-langgraph" - description: "A Temporal-backed LangGraph agent (harness variant) whose nodes run as Temporal activities" - replicaCount: 1 - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/project/__init__.py b/examples/tutorials/10_async/10_temporal/130_langgraph/project/__init__.py 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 7af9c5e68..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/project/acp.py +++ /dev/null @@ -1,34 +0,0 @@ -"""ACP server for the Temporal harness LangGraph agent. - -Follows the ``130_langgraph`` pattern: the Temporal ``LangGraphPlugin`` runs -graph nodes as Temporal activities. The agent logic lives in ``workflow.py`` -(the runtime) and ``graph.py`` (the LangGraph graph), executed by the Temporal -worker (``run_worker.py``), not by this HTTP process. - -The workflow uses ``emit_langgraph_messages`` to surface turn messages to -AgentEx. That helper is Temporal-specific and is not replaced by the unified -harness here (``UnifiedEmitter`` targets the non-Temporal async/sync channels). -""" - -from __future__ import annotations - -import os - -from dotenv import load_dotenv - -load_dotenv() - -from temporalio.contrib.langgraph import LangGraphPlugin - -from project.graph import GRAPH_NAME, build_graph -from agentex.lib.types.fastacp import TemporalACPConfig -from agentex.lib.sdk.fastacp.fastacp import FastACP - -acp = FastACP.create( - acp_type="async", - config=TemporalACPConfig( - type="temporal", - temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), - plugins=[LangGraphPlugin(graphs={GRAPH_NAME: build_graph()})], - ), -) diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/project/graph.py b/examples/tutorials/10_async/10_temporal/130_langgraph/project/graph.py deleted file mode 100644 index 7adba3ae4..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/project/graph.py +++ /dev/null @@ -1,85 +0,0 @@ -"""LangGraph graph for at130-langgraph โ€” nodes run as Temporal activities. - -Identical in structure to ``130_langgraph/project/graph.py``. The graph -definition is not affected by the harness migration; only the agent naming -changes. The LLM ``agent`` node runs as a durable Temporal activity; -the ``tools`` node runs inline in the workflow. -""" - -from __future__ import annotations - -import os -from typing import Any, Annotated -from datetime import datetime, timedelta - -_litellm_key = os.environ.get("LITELLM_API_KEY") -if _litellm_key: - os.environ.setdefault("OPENAI_API_KEY", _litellm_key) - -from typing_extensions import TypedDict - -from langgraph.graph import END, START, StateGraph -from langchain_openai import ChatOpenAI -from langchain_core.messages import ToolMessage, SystemMessage -from langgraph.graph.message import add_messages - -from project.tools import TOOLS - -_TOOLS_BY_NAME = {tool.name: tool for tool in TOOLS} - -GRAPH_NAME = "at130-langgraph" -MODEL_NAME = "gpt-4o" -SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. - -Current date and time: {timestamp} - -Be concise and use tools when they help answer the question.""" - - -class AgentState(TypedDict): - messages: Annotated[list[Any], add_messages] - - -async def agent_node(state: AgentState) -> dict[str, Any]: - """The 'agent' node โ€” one LLM call. Runs as a durable Temporal activity.""" - llm = ChatOpenAI(model=MODEL_NAME).bind_tools(TOOLS) - messages = state["messages"] - if not messages or not isinstance(messages[0], SystemMessage): - system = SystemMessage(content=SYSTEM_PROMPT.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))) - messages = [system, *messages] - return {"messages": [await llm.ainvoke(messages)]} - - -async def tools_node(state: AgentState) -> dict[str, Any]: - """Run the tool calls the model requested. Runs inline in the workflow.""" - last = state["messages"][-1] - results: list[Any] = [] - for call in getattr(last, "tool_calls", None) or []: - tool = _TOOLS_BY_NAME.get(call["name"]) - if tool is None: - output = f"Error: unknown tool {call['name']!r}. Available: {list(_TOOLS_BY_NAME)}" - else: - output = await tool.ainvoke(call["args"]) - results.append(ToolMessage(content=str(output), tool_call_id=call["id"], name=call["name"])) - return {"messages": results} - - -async def route_after_agent(state: AgentState) -> str: - """Go to the tools node if the model requested tools, else finish.""" - last = state["messages"][-1] - return "tools" if getattr(last, "tool_calls", None) else END - - -def build_graph() -> StateGraph: - """Build the agent graph; the LLM node runs as an activity, tools in the workflow.""" - builder = StateGraph(AgentState) - builder.add_node( - "agent", - agent_node, - metadata={"execute_in": "activity", "start_to_close_timeout": timedelta(minutes=5)}, - ) - builder.add_node("tools", tools_node, metadata={"execute_in": "workflow"}) - builder.add_edge(START, "agent") - builder.add_conditional_edges("agent", route_after_agent, {"tools": "tools", END: END}) - builder.add_edge("tools", "agent") - return builder diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/project/run_worker.py b/examples/tutorials/10_async/10_temporal/130_langgraph/project/run_worker.py deleted file mode 100644 index 4b31bf396..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/project/run_worker.py +++ /dev/null @@ -1,46 +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. -""" - -import asyncio - -from temporalio.contrib.langgraph import LangGraphPlugin - -from project.graph import GRAPH_NAME, build_graph -from project.workflow import AtHarnessLanggraphWorkflow -from agentex.lib.utils.debug import setup_debug_if_enabled -from agentex.lib.utils.logging import make_logger -from agentex.lib.environment_variables import EnvironmentVariables -from agentex.lib.core.temporal.activities import get_all_activities -from agentex.lib.core.temporal.workers.worker import AgentexWorker - -environment_variables = EnvironmentVariables.refresh() -logger = make_logger(__name__) - - -async def main(): - setup_debug_if_enabled() - - task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE - if task_queue_name is None: - raise ValueError("WORKFLOW_TASK_QUEUE is not set") - - worker = AgentexWorker( - task_queue=task_queue_name, - plugins=[LangGraphPlugin(graphs={GRAPH_NAME: build_graph()})], - ) - - await worker.run( - activities=get_all_activities(), - workflow=AtHarnessLanggraphWorkflow, - ) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/project/tools.py b/examples/tutorials/10_async/10_temporal/130_langgraph/project/tools.py deleted file mode 100644 index e7220016e..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/project/tools.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Tool definitions for the 130_langgraph temporal agent.""" - -from langchain_core.tools import Tool - - -def get_weather(city: str) -> str: - """Get the current weather for a city. - - Args: - city: The name of the city to get weather for. - - Returns: - A string describing the weather conditions. - """ - return f"The weather in {city} is sunny and 72ยฐF" - - -async def aget_weather(city: str) -> str: - """Native async tool entrypoint. - - ``tools_node`` runs inline in the Temporal workflow and invokes tools via - ``tool.ainvoke``. A sync-only tool forces LangChain to bridge through - ``run_in_executor`` (a thread pool), which the deterministic Temporal - workflow event loop forbids (``NotImplementedError``). Providing a real - coroutine keeps tool execution on the workflow loop. - """ - return get_weather(city) - - -weather_tool = Tool( - name="get_weather", - func=get_weather, - coroutine=aget_weather, - description="Get the current weather for a city. Input should be a city name.", -) - -TOOLS = [weather_tool] diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/project/workflow.py b/examples/tutorials/10_async/10_temporal/130_langgraph/project/workflow.py deleted file mode 100644 index b9224ca00..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/project/workflow.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Temporal workflow for at130-langgraph. - -Each turn the workflow runs the LangGraph graph (``project/graph.py``) via the -``temporalio.contrib.langgraph`` plugin. The plugin runs the LLM ``agent`` node -as a durable Temporal activity and the ``tools`` node inline in the workflow. - -Multi-turn memory is kept on the workflow instance (``self._messages``) โ€” it's -durable and replay-safe for free, so no checkpoint database is needed. -""" - -from __future__ import annotations - -import json -from typing import Any - -from temporalio import workflow -from temporalio.contrib.langgraph import graph as lg_graph - -from agentex.lib import adk -from project.graph import GRAPH_NAME -from agentex.lib.adk import emit_langgraph_messages -from agentex.protocol.acp import SendEventParams, CreateTaskParams -from agentex.lib.utils.logging import make_logger -from agentex.types.text_content import TextContent -from agentex.lib.environment_variables import EnvironmentVariables -from agentex.lib.core.temporal.types.workflow import SignalName -from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow - -environment_variables = EnvironmentVariables.refresh() - -if environment_variables.WORKFLOW_NAME is None: - raise ValueError("Environment variable WORKFLOW_NAME is not set") -if environment_variables.AGENT_NAME is None: - raise ValueError("Environment variable AGENT_NAME is not set") - -logger = make_logger(__name__) - - -@workflow.defn(name=environment_variables.WORKFLOW_NAME) -class AtHarnessLanggraphWorkflow(BaseWorkflow): - """Runs the LangGraph agent each turn; its nodes run as Temporal activities.""" - - def __init__(self) -> None: - super().__init__(display_name=environment_variables.AGENT_NAME) - self._complete_task = False - self._messages: list[Any] = [] - self._emitted = 0 - - @workflow.signal(name=SignalName.RECEIVE_EVENT) - async def on_task_event_send(self, params: SendEventParams) -> None: - """Echo the user's message, run the graph, surface the new messages.""" - await adk.messages.create(task_id=params.task.id, content=params.event.content) - self._messages.append({"role": "user", "content": params.event.content.content}) - - compiled = lg_graph(GRAPH_NAME).compile() - result = await compiled.ainvoke({"messages": self._messages}) - self._messages = result["messages"] - - await emit_langgraph_messages(self._messages[self._emitted :], params.task.id) - self._emitted = len(self._messages) - - @workflow.signal - async def complete_task_signal(self) -> None: - self._complete_task = True - - @workflow.run - async def on_task_create(self, params: CreateTaskParams) -> str: - await adk.messages.create( - task_id=params.task.id, - content=TextContent( - author="agent", - content=( - f"Task initialized with params:\n{json.dumps(params.params, indent=2)}\n\n" - "Send me a message and I'll respond using a LangGraph agent whose nodes " - "run as durable Temporal activities." - ), - ), - ) - await workflow.wait_condition(lambda: self._complete_task, timeout=None) - return "Task completed" diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/pyproject.toml b/examples/tutorials/10_async/10_temporal/130_langgraph/pyproject.toml deleted file mode 100644 index 6d2262761..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/pyproject.toml +++ /dev/null @@ -1,40 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "at130-langgraph" -version = "0.1.0" -description = "A Temporal-backed LangGraph agent (harness variant) whose nodes run as Temporal activities" -requires-python = ">=3.12" -dependencies = [ - "agentex-sdk", - "scale-gp", - "temporalio[langgraph]>=1.27.0", - "langchain-openai", - "langchain-core", - "grandalf", - "python-dotenv", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "httpx", - "black", - "isort", - "flake8", - "debugpy>=1.8.15", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] - -[tool.black] -line-length = 88 -target-version = ['py312'] - -[tool.isort] -profile = "black" -line_length = 88 diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/130_langgraph/tests/test_agent.py deleted file mode 100644 index f2292389f..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/tests/test_agent.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Integration tests for the Temporal harness LangGraph agent (live agent required). - -These drive a *running* agent over the AgentEx API and verify that: -- the agent sends a welcome message on task creation, -- a weather question triggers a tool_request / tool_response round-trip - (proving the LLM node ran as a Temporal activity and the tool node ran), -- the final answer reflects the tool output. - -To run: -1. Start the agent (worker + ACP server): ``agentex agents run --manifest manifest.yaml`` -2. Set AGENTEX_API_BASE_URL if not using the default -3. ``pytest tests/test_agent.py -v`` -""" - -import os -import uuid - -import pytest -import pytest_asyncio -from test_utils.async_utils import ( - poll_messages, - send_event_and_poll_yielding, -) - -from agentex import AsyncAgentex -from agentex.types.task_message import TaskMessage -from agentex.types.agent_rpc_params import ParamsCreateTaskRequest - -AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") -AGENT_NAME = os.environ.get("AGENT_NAME", "at130-langgraph") - - -@pytest_asyncio.fixture -async def client(): - client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) - yield client - await client.close() - - -@pytest.fixture -def agent_name(): - return AGENT_NAME - - -@pytest_asyncio.fixture -async def agent_id(client, agent_name): - agents = await client.agents.list() - for agent in agents: - if agent.name == agent_name: - return agent.id - raise ValueError(f"Agent with name {agent_name} not found.") - - -class TestNonStreamingEvents: - """The Temporal-backed LangGraph agent responds and uses tools.""" - - @pytest.mark.asyncio - async def test_send_event_and_poll(self, client: AsyncAgentex, agent_id: str): - """Create a task, ask about weather, verify the tool round-trip.""" - task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) - task = task_response.result - assert task is not None - - task_creation_found = False - async for message in poll_messages(client=client, task_id=task.id, timeout=30, sleep_interval=1.0): - assert isinstance(message, TaskMessage) - if message.content and message.content.type == "text" and message.content.author == "agent": - task_creation_found = True - break - assert task_creation_found, "Task creation welcome message not found" - - seen_tool_request = False - seen_tool_response = False - final_message = None - async for message in send_event_and_poll_yielding( - client=client, - agent_id=agent_id, - task_id=task.id, - user_message="What is the weather in San Francisco? Use your tool.", - timeout=60, - sleep_interval=1.0, - ): - assert isinstance(message, TaskMessage) - - if message.content and message.content.type == "tool_request": - seen_tool_request = True - if message.content and message.content.type == "tool_response": - seen_tool_response = True - - if message.content and message.content.type == "text" and message.content.author == "agent": - final_message = message - content_length = len(getattr(message.content, "content", "") or "") - if getattr(message, "streaming_status", None) in (None, "DONE") and content_length > 0: - if seen_tool_response: - break - - assert seen_tool_request, "Expected a tool_request (agent calling get_weather)" - assert seen_tool_response, "Expected a tool_response (get_weather result)" - assert final_message is not None, "Expected a final agent text message" - final_text = getattr(final_message.content, "content", None) if final_message.content else None - assert isinstance(final_text, str) and len(final_text) > 0 - assert "72" in final_text, "Expected weather response to mention 72ยฐF" - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/.dockerignore b/examples/tutorials/10_async/10_temporal/140_claude_code/.dockerignore deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/10_async/10_temporal/140_claude_code/.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/140_claude_code/Dockerfile b/examples/tutorials/10_async/10_temporal/140_claude_code/Dockerfile deleted file mode 100644 index c909ee6c7..000000000 --- a/examples/tutorials/10_async/10_temporal/140_claude_code/Dockerfile +++ /dev/null @@ -1,46 +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/ - -RUN apt-get update && apt-get install -y \ - htop \ - vim \ - curl \ - tar \ - python3-dev \ - postgresql-client \ - build-essential \ - libpq-dev \ - gcc \ - cmake \ - netcat-openbsd \ - nodejs \ - npm \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -RUN uv pip install --system --upgrade pip setuptools wheel - -RUN npm install -g @anthropic-ai/claude-code || true - -ENV UV_HTTP_TIMEOUT=1000 - -COPY 10_async/10_temporal/140_claude_code/pyproject.toml /app/140_claude_code/pyproject.toml -COPY 10_async/10_temporal/140_claude_code/README.md /app/140_claude_code/README.md - -WORKDIR /app/140_claude_code - -COPY 10_async/10_temporal/140_claude_code/project /app/140_claude_code/project -COPY 10_async/10_temporal/140_claude_code/tests /app/140_claude_code/tests -COPY test_utils /app/test_utils - -RUN uv pip install --system .[dev] - -ENV PYTHONPATH=/app - -ENV AGENT_NAME=at140-claude-code - -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] - -# When deploying the worker, replace the CMD with: -# CMD ["python", "project/run_worker.py"] diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/README.md b/examples/tutorials/10_async/10_temporal/140_claude_code/README.md deleted file mode 100644 index 61cc94183..000000000 --- a/examples/tutorials/10_async/10_temporal/140_claude_code/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# Tutorial 140 (async/temporal): Temporal Claude Code Agent - -This tutorial demonstrates how to build a **Temporal-backed** agent that -spawns the Claude Code CLI as a local subprocess and delivers its output -through the Agentex unified harness surface via ``ClaudeCodeTurn`` and -``UnifiedEmitter.auto_send_turn``, with Temporal providing durable execution -and crash recovery. - -## Key Concepts - -### Temporal + ClaudeCodeTurn - -The Temporal workflow (``project/workflow.py``) holds state durably. Each user -message arrives as a signal (``on_task_event_send``), spawns the Claude Code -CLI locally, wraps the stdout line stream in ``ClaudeCodeTurn``, and pushes -events to the task's Redis stream via ``UnifiedEmitter.auto_send_turn``. - -``workflow.now()`` is passed as ``created_at`` so message timestamps are -deterministic under Temporal replay. - -### Multi-turn session resume - -The workflow persists the Claude Code ``session_id`` from the ``result`` -envelope. On the next turn, ``-r `` is passed to the CLI to -resume the conversation. Temporal's durable state ensures the session_id -survives worker crashes. - -### Note on subprocess in workflow code - -For simplicity, this tutorial spawns the subprocess directly inside the -workflow signal handler. For production use, move the spawn into a custom -Temporal activity so each subprocess invocation gets independent retry and -timeout guarantees. See -``examples/tutorials/10_async/10_temporal/030_custom_activities/`` for -that pattern. - -### Injectable spawn seam - -``_spawn_claude`` in ``project/workflow.py`` is a top-level async generator. -Tests monkeypatch it to inject pre-recorded stream-json lines so offline -unit tests run without the CLI. - -## Files - -| File | Description | -|------|-------------| -| ``project/acp.py`` | Thin ACP server; wires Temporal (no handlers) | -| ``project/workflow.py`` | Temporal workflow + ``_spawn_claude`` seam | -| ``project/run_worker.py`` | Temporal worker entry point | -| ``tests/test_agent.py`` | Live integration tests (needs CLI + Temporal + API key) | -| ``tests/test_agent_offline.py`` | Offline unit tests with injected fake subprocess | -| ``manifest.yaml`` | Agent configuration | - -## Running Locally (live) - -Requires Temporal server, the ``claude`` CLI, and ``ANTHROPIC_API_KEY``: - -```bash -npm install -g @anthropic-ai/claude-code -export ANTHROPIC_API_KEY=sk-ant-... -agentex agents run -``` - -## Running Offline Tests - -No CLI, Temporal, or API key needed: - -```bash -uv run pytest tests/test_agent_offline.py -v -``` - -## Notes - -- Production isolation (sandbox, secrets, MCP) is the golden agent's concern. -- The subprocess spawn should be moved to a custom activity in production. -- The ``--verbose`` flag is included to match the golden agent's invocation. diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/manifest.yaml b/examples/tutorials/10_async/10_temporal/140_claude_code/manifest.yaml deleted file mode 100644 index 9328b1713..000000000 --- a/examples/tutorials/10_async/10_temporal/140_claude_code/manifest.yaml +++ /dev/null @@ -1,62 +0,0 @@ -build: - context: - root: ../../../ - include_paths: - - 10_async/10_temporal/140_claude_code - - test_utils - dockerfile: 10_async/10_temporal/140_claude_code/Dockerfile - dockerignore: 10_async/10_temporal/140_claude_code/.dockerignore - -local_development: - agent: - port: 8000 - host_address: host.docker.internal - paths: - acp: project/acp.py - worker: project/run_worker.py - -agent: - acp_type: async - name: at140-claude-code - description: A Temporal-backed Claude Code agent streaming the unified harness surface via a local CLI subprocess - - temporal: - enabled: true - workflows: - - name: at140-claude-code - queue_name: at140_claude_code_queue - - credentials: - - env_var_name: ANTHROPIC_API_KEY - secret_name: anthropic-api-key - secret_key: api-key - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - - env_var_name: SGP_API_KEY - secret_name: sgp-api-key - secret_key: api-key - - env_var_name: SGP_ACCOUNT_ID - secret_name: sgp-account-id - secret_key: account-id - - env_var_name: SGP_CLIENT_BASE_URL - secret_name: sgp-client-base-url - secret_key: url - -deployment: - image: - repository: "" - tag: "latest" - - global: - agent: - name: "at140-claude-code" - description: "A Temporal-backed Claude Code agent streaming via local CLI subprocess" - replicaCount: 1 - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/project/__init__.py b/examples/tutorials/10_async/10_temporal/140_claude_code/project/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/project/acp.py b/examples/tutorials/10_async/10_temporal/140_claude_code/project/acp.py deleted file mode 100644 index 07258f6d8..000000000 --- a/examples/tutorials/10_async/10_temporal/140_claude_code/project/acp.py +++ /dev/null @@ -1,31 +0,0 @@ -"""ACP server for the Temporal Claude Code tutorial. - -This file is intentionally thin. When ``acp_type="async"`` is combined -with ``TemporalACPConfig``, FastACP auto-wires: - - HTTP task/create -> @workflow.run on the workflow class - HTTP task/event/send -> @workflow.signal(SignalName.RECEIVE_EVENT) - HTTP task/cancel -> workflow cancellation via the Temporal client - -The actual agent code lives in ``project/workflow.py`` and is executed by -the Temporal worker (``project/run_worker.py``), not by this HTTP process. -""" - -from __future__ import annotations - -import os - -from dotenv import load_dotenv - -load_dotenv() - -from agentex.lib.types.fastacp import TemporalACPConfig -from agentex.lib.sdk.fastacp.fastacp import FastACP - -acp = FastACP.create( - acp_type="async", - config=TemporalACPConfig( - type="temporal", - temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), - ), -) diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/project/activities.py b/examples/tutorials/10_async/10_temporal/140_claude_code/project/activities.py deleted file mode 100644 index dcba0f9a7..000000000 --- a/examples/tutorials/10_async/10_temporal/140_claude_code/project/activities.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Temporal activity for the Claude Code tutorial. - -Subprocess spawning (and any other I/O) must run inside a Temporal *activity*, -not in workflow code. Temporal runs workflow + signal-handler bodies on a -deterministic sandbox event loop that does not implement ``subprocess_exec`` -(or threads / sockets), so spawning the CLI directly in the signal handler -raises ``NotImplementedError``. This activity runs the Claude Code CLI, drives -the ``ClaudeCodeTurn`` through ``UnifiedEmitter.auto_send_turn`` (the async -Redis push path), and returns the turn result to the workflow. - -The ``_spawn_claude`` async generator is an injectable seam: offline tests -provide a fake that yields pre-recorded stdout lines so no real CLI runs. -""" - -from __future__ import annotations - -import asyncio -from typing import Any, AsyncIterator -from datetime import datetime - -from temporalio import activity - -from agentex.lib.adk import ClaudeCodeTurn -from agentex.lib.core.harness import UnifiedEmitter -from agentex.lib.utils.logging import make_logger -from agentex.lib.utils.model_utils import BaseModel - -logger = make_logger(__name__) - -RUN_CLAUDE_CODE_TURN_ACTIVITY = "run_claude_code_turn" - - -class RunClaudeCodeTurnParams(BaseModel): - """Arguments for one Claude Code turn run inside an activity.""" - - task_id: str - prompt: str - trace_id: str | None = None - parent_span_id: str | None = None - session_id: str | None = None - created_at: datetime | None = None - - -class RunClaudeCodeTurnResult(BaseModel): - """Result returned from the activity to the workflow.""" - - final_text: str - session_id: str | None = None - - -async def _spawn_claude(prompt: str, session_id: str | None = None) -> AsyncIterator[str]: - """Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines. - - Pass ``session_id`` to resume a previous Claude Code session (multi-turn - memory via ``-r ``). - - Injectable seam: tests monkeypatch this with a fake async iterator so no - real CLI invocation is needed offline. - """ - cmd = [ - "claude", - "-p", - "--output-format", - "stream-json", - "--verbose", - ] - if session_id: - cmd.extend(["-r", session_id]) - - proc = await asyncio.create_subprocess_exec( - *cmd, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - assert proc.stdout is not None - assert proc.stdin is not None - - proc.stdin.write(prompt.encode()) - proc.stdin.close() - - # Drain stderr concurrently. With --verbose, Claude Code can write enough to - # stderr to fill the OS pipe buffer; if we only read stdout, the CLI blocks - # on its stderr write while we block reading stdout โ€” a deadlock. A - # background task keeps stderr flowing so stdout never stalls. - async def _drain_stderr() -> None: - assert proc.stderr is not None - async for _ in proc.stderr: - pass - - stderr_task = asyncio.create_task(_drain_stderr()) - - try: - buffer = "" - async for chunk in proc.stdout: - buffer += chunk.decode("utf-8", errors="replace") - while "\n" in buffer: - line, buffer = buffer.split("\n", 1) - line = line.strip() - if line: - yield line - - if buffer.strip(): - yield buffer.strip() - - await proc.wait() - finally: - # Release the subprocess and stderr drain task even if the consumer - # abandons the generator early (task cancellation / client disconnect): - # cancel the drain task and terminate+reap the process if it is still - # running, so neither is leaked. - stderr_task.cancel() - try: - await stderr_task - except asyncio.CancelledError: - pass - if proc.returncode is None: - try: - proc.terminate() - except ProcessLookupError: - pass - await proc.wait() - - -@activity.defn(name=RUN_CLAUDE_CODE_TURN_ACTIVITY) -async def run_claude_code_turn(params: RunClaudeCodeTurnParams) -> dict[str, Any]: - """Run one Claude Code turn end-to-end and stream events to the task. - - Runs in an activity (real asyncio loop) so subprocess I/O is permitted. - """ - emitter = UnifiedEmitter( - task_id=params.task_id, - trace_id=params.trace_id, - parent_span_id=params.parent_span_id, - ) - turn = ClaudeCodeTurn(_spawn_claude(params.prompt, session_id=params.session_id)) - result = await emitter.auto_send_turn(turn, created_at=params.created_at) - - return RunClaudeCodeTurnResult(final_text=result.final_text, session_id=turn.session_id).model_dump() diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/project/run_worker.py b/examples/tutorials/10_async/10_temporal/140_claude_code/project/run_worker.py deleted file mode 100644 index 58802737e..000000000 --- a/examples/tutorials/10_async/10_temporal/140_claude_code/project/run_worker.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Temporal worker for the Claude Code tutorial. - -Run as a separate long-lived process alongside the ACP HTTP server. The -worker polls Temporal for workflow + activity tasks and executes them. - -The Claude Code CLI subprocess runs in the ``run_claude_code_turn`` activity -(registered below alongside the built-in Agentex activities), because -subprocess I/O is not permitted on the Temporal workflow event loop. -""" - -import asyncio - -from project.workflow import At140ClaudeCodeWorkflow -from project.activities import run_claude_code_turn -from agentex.lib.utils.debug import setup_debug_if_enabled -from agentex.lib.utils.logging import make_logger -from agentex.lib.environment_variables import EnvironmentVariables -from agentex.lib.core.temporal.activities import get_all_activities -from agentex.lib.core.temporal.workers.worker import AgentexWorker - -environment_variables = EnvironmentVariables.refresh() -logger = make_logger(__name__) - - -async def main(): - setup_debug_if_enabled() - - task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE - if task_queue_name is None: - raise ValueError("WORKFLOW_TASK_QUEUE is not set") - - worker = AgentexWorker(task_queue=task_queue_name) - - await worker.run( - activities=[run_claude_code_turn, *get_all_activities()], - workflow=At140ClaudeCodeWorkflow, - ) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/project/workflow.py b/examples/tutorials/10_async/10_temporal/140_claude_code/project/workflow.py deleted file mode 100644 index 7f50ba8d5..000000000 --- a/examples/tutorials/10_async/10_temporal/140_claude_code/project/workflow.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Temporal workflow for the Claude Code tutorial. - -Holds conversation state (session_id for multi-turn resume) durably across -crashes. Each user message triggers ``on_task_event_send``, which delegates the -turn to the ``run_claude_code_turn`` activity. The activity spawns the Claude -Code CLI, wraps its stdout in ``ClaudeCodeTurn``, and delivers the turn via -``UnifiedEmitter.auto_send_turn`` (the async Redis push path). - -Note on subprocess inside Temporal ------------------------------------- -Subprocess (and all other) I/O must run in a Temporal *activity*, never in -workflow code. Temporal runs workflow + signal-handler bodies on a -deterministic sandbox event loop that does not implement ``subprocess_exec`` -(spawning the CLI there raises ``NotImplementedError``). The activity also gets -Temporal's retry + timeout guarantees. See -``examples/tutorials/10_async/10_temporal/030_custom_activities/`` for the -activity pattern. -""" - -from __future__ import annotations - -import os -import json -from datetime import timedelta - -from temporalio import workflow - -from agentex.lib import adk -from agentex.lib.types.acp import SendEventParams, CreateTaskParams -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.types.text_content import TextContent -from agentex.lib.environment_variables import EnvironmentVariables -from agentex.lib.core.temporal.types.workflow import SignalName -from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config - -with workflow.unsafe.imports_passed_through(): - from project.activities import RunClaudeCodeTurnParams, run_claude_code_turn - -add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SGP_API_KEY", ""), - sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - ) -) - -environment_variables = EnvironmentVariables.refresh() - -if environment_variables.WORKFLOW_NAME is None: - raise ValueError("Environment variable WORKFLOW_NAME is not set") -if environment_variables.AGENT_NAME is None: - raise ValueError("Environment variable AGENT_NAME is not set") - -logger = make_logger(__name__) - - -@workflow.defn(name=environment_variables.WORKFLOW_NAME) -class At140ClaudeCodeWorkflow(BaseWorkflow): - """Temporal workflow that runs Claude Code locally for each user message. - - Persists the Claude Code session_id across turns so the CLI can resume - the conversation (``-r ``). Temporal's durable state ensures - the session_id survives worker crashes. - """ - - def __init__(self): - super().__init__(display_name=environment_variables.AGENT_NAME) - self._complete_task = False - self._turn_number = 0 - # Claude Code session_id for multi-turn resume. - self._session_id: str | None = None - - @workflow.signal(name=SignalName.RECEIVE_EVENT) - async def on_task_event_send(self, params: SendEventParams) -> None: - """Handle a user message: spawn Claude Code and push events to the task stream.""" - self._turn_number += 1 - task_id = params.task.id - prompt = params.event.content.content - logger.info("Turn %d for task %s", self._turn_number, task_id) - - await adk.messages.create(task_id=task_id, content=params.event.content) - - async with adk.tracing.span( - trace_id=task_id, - task_id=task_id, - name=f"Turn {self._turn_number}", - input={"message": prompt}, - ) as span: - # Delegate the subprocess turn to an activity: subprocess I/O is not - # permitted on the Temporal workflow event loop. The activity streams - # events to the task and returns the final text + session_id. - # workflow.now() gives a deterministic timestamp under replay. - result = await workflow.execute_activity( - run_claude_code_turn, - RunClaudeCodeTurnParams( - task_id=task_id, - prompt=prompt, - trace_id=task_id, - parent_span_id=span.id if span else None, - session_id=self._session_id, - created_at=workflow.now(), - ), - start_to_close_timeout=timedelta(minutes=5), - ) - - # Capture session_id to enable Claude Code resume on the next turn. - sid = result.get("session_id") - if sid: - self._session_id = sid - - if span: - span.output = {"final_text": result.get("final_text")} - - @workflow.run - async def on_task_create(self, params: CreateTaskParams) -> str: - logger.info("Task created: %s", params.task.id) - - await adk.messages.create( - task_id=params.task.id, - content=TextContent( - author="agent", - content=( - f"Task initialized with params:\n{json.dumps(params.params, indent=2)}\n" - "Send me a message and I'll run it through Claude Code locally." - ), - ), - ) - - await workflow.wait_condition(lambda: self._complete_task, timeout=None) - return "Task completed" - - @workflow.signal - async def complete_task_signal(self) -> None: - logger.info("Received complete_task signal") - self._complete_task = True diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/pyproject.toml b/examples/tutorials/10_async/10_temporal/140_claude_code/pyproject.toml deleted file mode 100644 index b9d517267..000000000 --- a/examples/tutorials/10_async/10_temporal/140_claude_code/pyproject.toml +++ /dev/null @@ -1,27 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "at140-claude-code" -version = "0.1.0" -description = "A Temporal-backed Claude Code agent streaming the unified harness surface via a local CLI subprocess" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "agentex-sdk", - "scale-gp", - "temporalio>=1.18.2", - "python-dotenv>=1.0,<2", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "httpx", - "debugpy>=1.8.15", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/140_claude_code/tests/test_agent.py deleted file mode 100644 index 767c707b9..000000000 --- a/examples/tutorials/10_async/10_temporal/140_claude_code/tests/test_agent.py +++ /dev/null @@ -1,249 +0,0 @@ -"""Tests for the Temporal Claude Code tutorial agent. - -LIVE tests (``TestClaudeCodeLive``): - - Require Temporal server, the ACP server, the Temporal worker, the ``claude`` - CLI on PATH, and ``ANTHROPIC_API_KEY`` set. - - Run the full agent end-to-end against a live Agentex server. - - Skipped automatically when ``CLAUDE_LIVE_TESTS`` is not set to ``1``. - -OFFLINE unit tests (``TestClaudeCodeOffline``): - - Inject a fake async iterator of pre-recorded stream-json lines. - - Assert the ``ClaudeCodeTurn`` + ``UnifiedEmitter`` pipeline drives - ``auto_send_turn``, populates usage, and satisfies the ``HarnessTurn`` - protocol. - - Always run -- no CLI or API key needed. -""" - -from __future__ import annotations - -import os -import json -from typing import AsyncIterator - -import pytest - -from agentex.types.task_message import TaskMessage - -# --------------------------------------------------------------------------- -# Recorded stream-json fixtures -# --------------------------------------------------------------------------- - -_TEXT_ONLY_LINES: list[str] = [ - json.dumps({"type": "system", "subtype": "init", "session_id": "sess-temporal-offline-1"}), - json.dumps( - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "Hello from Temporal Claude Code!"}]}, - } - ), - json.dumps( - { - "type": "result", - "session_id": "sess-temporal-offline-1", - "usage": {"input_tokens": 15, "output_tokens": 7}, - "cost_usd": 0.00015, - "duration_ms": 350, - "num_turns": 1, - } - ), -] - - -async def _fake_lines(lines: list[str]) -> AsyncIterator[str]: - """Async iterator of pre-recorded stream-json lines (no subprocess).""" - for line in lines: - yield line - - -# --------------------------------------------------------------------------- -# Fake streaming backend -# --------------------------------------------------------------------------- - - -class _FakeCtx: - def __init__(self, sink, content_type, initial_content): - self.sink = sink - self.content_type = content_type - self.task_message = TaskMessage(id="msg-t1", task_id="task-temporal-offline", content=initial_content) - - async def __aenter__(self): - self.sink.append(("open", self.content_type)) - return self - - async def __aexit__(self, *a): - await self.close() - return False - - async def close(self): - self.sink.append(("close", self.content_type)) - - async def stream_update(self, update): - self.sink.append(("update", update)) - return update - - -class _FakeStreaming: - def __init__(self): - self.sink: list = [] - - def streaming_task_message_context(self, task_id, initial_content, streaming_mode="coalesced", created_at=None): # noqa: ARG002 - ctype = getattr(initial_content, "type", None) - self.sink.append(("ctx", ctype)) - return _FakeCtx(self.sink, ctype, initial_content) - - -# --------------------------------------------------------------------------- -# Offline tests (always run -- no CLI or API key needed) -# --------------------------------------------------------------------------- - - -class TestClaudeCodeOffline: - """Unit tests that run without a real claude CLI, Temporal, or network.""" - - @pytest.mark.asyncio - async def test_auto_send_text_only_produces_output(self): - """auto_send_turn result carries the agent's reply text.""" - from agentex.lib.adk import ClaudeCodeTurn - from agentex.lib.core.harness import UnifiedEmitter - - fake_streaming = _FakeStreaming() - turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) - emitter = UnifiedEmitter( - task_id="offline-temporal", - trace_id=None, - parent_span_id=None, - tracer=False, - streaming=fake_streaming, - ) - result = await emitter.auto_send_turn(turn) - assert "Hello from Temporal Claude Code" in result.final_text - - @pytest.mark.asyncio - async def test_usage_populated_after_stream_exhausted(self): - """Usage is populated after the events stream is exhausted.""" - from agentex.lib.adk import ClaudeCodeTurn - from agentex.lib.core.harness import UnifiedEmitter - - fake_streaming = _FakeStreaming() - turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) - emitter = UnifiedEmitter( - task_id="t", - trace_id=None, - parent_span_id=None, - tracer=False, - streaming=fake_streaming, - ) - await emitter.auto_send_turn(turn) - usage = turn.usage() - assert usage.input_tokens == 15 - assert usage.output_tokens == 7 - assert usage.num_llm_calls == 1 - - @pytest.mark.asyncio - async def test_stream_task_message_done_present(self): - """StreamTaskMessageDone must appear via yield_turn on a ClaudeCodeTurn.""" - from agentex.lib.adk import ClaudeCodeTurn - from agentex.lib.core.harness import UnifiedEmitter - from agentex.types.task_message_update import StreamTaskMessageDone - - turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) - emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) - events = [e async for e in emitter.yield_turn(turn)] - assert any(isinstance(e, StreamTaskMessageDone) for e in events), ( - "Expected at least one StreamTaskMessageDone event" - ) - - @pytest.mark.asyncio - async def test_session_id_captured_in_result_envelope(self): - """The result envelope carries session_id (multi-turn resume support).""" - from agentex.lib.adk import ClaudeCodeTurn - from agentex.lib.core.harness import UnifiedEmitter - - fake_streaming = _FakeStreaming() - turn = ClaudeCodeTurn(_fake_lines(_TEXT_ONLY_LINES)) - emitter = UnifiedEmitter( - task_id="t", - trace_id=None, - parent_span_id=None, - tracer=False, - streaming=fake_streaming, - ) - await emitter.auto_send_turn(turn) - assert turn._result_envelope is not None - assert turn._result_envelope.get("session_id") == "sess-temporal-offline-1" - - -# --------------------------------------------------------------------------- -# Live tests (skipped unless CLAUDE_LIVE_TESTS=1) -# --------------------------------------------------------------------------- - -pytestmark_live = pytest.mark.skipif( - not os.environ.get("CLAUDE_LIVE_TESTS"), - reason="Set CLAUDE_LIVE_TESTS=1 and ensure the `claude` CLI + ANTHROPIC_API_KEY are available", -) - -AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") -AGENT_NAME = os.environ.get("AGENT_NAME", "at140-claude-code") - - -@pytestmark_live -class TestClaudeCodeLive: - """Live Temporal tests -- needs Temporal server + the claude CLI + ANTHROPIC_API_KEY.""" - - @pytest.fixture - def client(self): - from agentex import Agentex - - return Agentex(base_url=AGENTEX_API_BASE_URL) - - @pytest.fixture - def agent_name(self): - return AGENT_NAME - - @pytest.fixture - def agent_id(self, client, agent_name): - agents = client.agents.list() - for agent in agents: - if agent.name == agent_name: - return agent.id - raise ValueError(f"Agent {agent_name!r} not found.") - - def test_send_simple_message(self, client, agent_id: str): - """Create a task, send a message, and poll until a response appears.""" - import time - import uuid - - from agentex.types import TextContentParam - from agentex.types.agent_rpc_params import ParamsSendEventRequest, ParamsCreateTaskRequest - - task = client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)).result - assert task is not None - task_id = task.id - - client.agents.send_event( - agent_id=agent_id, - params=ParamsSendEventRequest( - task_id=task_id, - content=TextContentParam( - author="user", - content="Reply with exactly three words: hello from claude", - type="text", - ), - ), - ) - - deadline = time.monotonic() + 90 - while time.monotonic() < deadline: - msgs = client.messages.list(task_id=task_id) - agent_msgs = [m for m in msgs if getattr(m.content, "author", None) == "agent"] - response_msgs = [m for m in agent_msgs if "Task initialized" not in str(getattr(m.content, "content", ""))] - if response_msgs: - assert len(response_msgs) >= 1 - return - time.sleep(3) - - raise AssertionError("No agent response received within 90 s") - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/tests/test_agent_offline.py b/examples/tutorials/10_async/10_temporal/140_claude_code/tests/test_agent_offline.py deleted file mode 100644 index 1adc553f1..000000000 --- a/examples/tutorials/10_async/10_temporal/140_claude_code/tests/test_agent_offline.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Offline unit tests for the Temporal Claude Code tutorial agent. - -These tests do NOT require the ``claude`` CLI, Temporal, or ANTHROPIC_API_KEY. -They inject a fake async iterator of pre-recorded stream-json lines in place of -the real subprocess spawn and a fake streaming backend, then assert that the -workflow's turn logic correctly drives ``UnifiedEmitter.auto_send_turn``. - -The injection seam is the ``_spawn_claude`` function in ``project/workflow.py``. -Tests monkeypatch it with a coroutine returning a pre-recorded async iterator. -""" - -from __future__ import annotations - -import json -from typing import AsyncIterator - -import pytest - -from agentex.lib.adk import ClaudeCodeTurn -from agentex.lib.core.harness import UnifiedEmitter -from agentex.types.task_message import TaskMessage - -# --------------------------------------------------------------------------- -# Recorded fixtures -# --------------------------------------------------------------------------- - -_TEXT_ONLY_LINES: list[str] = [ - json.dumps({"type": "system", "subtype": "init", "session_id": "sess-temporal-1"}), - json.dumps( - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "Hello from Temporal Claude Code!"}]}, - } - ), - json.dumps( - { - "type": "result", - "session_id": "sess-temporal-1", - "usage": {"input_tokens": 15, "output_tokens": 7}, - "cost_usd": 0.00015, - "duration_ms": 350, - "num_turns": 1, - } - ), -] - -_TOOL_CALL_LINES: list[str] = [ - json.dumps({"type": "system", "subtype": "init", "session_id": "sess-temporal-2"}), - json.dumps( - { - "type": "assistant", - "message": { - "content": [ - { - "type": "tool_use", - "id": "tool_temporal", - "name": "Bash", - "input": {"command": "ls /tmp"}, - } - ] - }, - } - ), - json.dumps( - { - "type": "user", - "message": { - "content": [ - { - "type": "tool_result", - "tool_use_id": "tool_temporal", - "content": "file1\nfile2\n", - "is_error": False, - } - ] - }, - } - ), - json.dumps( - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "Listed files."}]}, - } - ), - json.dumps( - { - "type": "result", - "session_id": "sess-temporal-2", - "usage": {"input_tokens": 30, "output_tokens": 12}, - "cost_usd": 0.0004, - "duration_ms": 600, - "num_turns": 1, - } - ), -] - - -# --------------------------------------------------------------------------- -# Fake streaming backend -# --------------------------------------------------------------------------- - - -class _FakeCtx: - def __init__(self, sink, content_type, initial_content): - self.sink = sink - self.content_type = content_type - self.task_message = TaskMessage(id="msg-t1", task_id="task-temporal-offline", content=initial_content) - - async def __aenter__(self): - self.sink.append(("open", self.content_type)) - return self - - async def __aexit__(self, *a): - await self.close() - return False - - async def close(self): - self.sink.append(("close", self.content_type)) - - async def stream_update(self, update): - self.sink.append(("update", update)) - return update - - -class _FakeStreaming: - def __init__(self): - self.sink: list = [] - - def streaming_task_message_context(self, task_id, initial_content, streaming_mode="coalesced", created_at=None): # noqa: ARG002 - ctype = getattr(initial_content, "type", None) - self.sink.append(("ctx", ctype)) - return _FakeCtx(self.sink, ctype, initial_content) - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -async def _fake_lines(lines: list[str]) -> AsyncIterator[str]: - for line in lines: - yield line - - -async def _run_turn(lines: list[str]): - fake_streaming = _FakeStreaming() - turn = ClaudeCodeTurn(_fake_lines(lines)) - emitter = UnifiedEmitter( - task_id="offline-temporal", - trace_id=None, - parent_span_id=None, - tracer=False, - streaming=fake_streaming, - ) - result = await emitter.auto_send_turn(turn) - return result, fake_streaming.sink, turn - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_text_only_produces_agent_output(): - result, sink, _ = await _run_turn(_TEXT_ONLY_LINES) - assert "Hello from Temporal Claude Code" in result.final_text - - -@pytest.mark.asyncio -async def test_usage_from_result_envelope(): - """Usage is available from turn.usage() after the events are exhausted. - - UnifiedEmitter.auto_send_turn evaluates turn.usage() eagerly before the - async generator is consumed, so result.usage is a pre-exhaust snapshot. - Read usage directly from the turn after _run_turn completes instead. - """ - result, _, turn = await _run_turn(_TEXT_ONLY_LINES) - usage = turn.usage() - assert usage.input_tokens == 15 - assert usage.output_tokens == 7 - assert usage.num_llm_calls == 1 - - -@pytest.mark.asyncio -async def test_session_id_captured_in_result_envelope(): - """Verify the result envelope carries session_id (multi-turn resume support).""" - _, _, turn = await _run_turn(_TEXT_ONLY_LINES) - assert turn._result_envelope is not None - assert turn._result_envelope.get("session_id") == "sess-temporal-1" - - -@pytest.mark.asyncio -async def test_tool_call_context_types(): - result, sink, _ = await _run_turn(_TOOL_CALL_LINES) - opened = [s for s in sink if s[0] == "open"] - content_types = [s[1] for s in opened] - assert "tool_request" in content_types - assert "text" in content_types - - -@pytest.mark.asyncio -async def test_spawn_seam_concept(): - """Demonstrate the injectable spawn seam pattern used in project/workflow.py. - - ``_spawn_claude(prompt, session_id=None)`` is a top-level async generator. - A drop-in replacement (e.g. via monkeypatch) supplies pre-recorded lines - and captures call arguments. The session_id parameter enables multi-turn - resume (``claude -r ``). - """ - called: list[tuple] = [] - - async def _fake_spawn(prompt: str, session_id=None) -> AsyncIterator[str]: - called.append((prompt, session_id)) - for line in _TEXT_ONLY_LINES: - yield line - - fake_streaming = _FakeStreaming() - turn = ClaudeCodeTurn(_fake_spawn("temporal prompt", session_id="old-sid")) - emitter = UnifiedEmitter( - task_id="t", - trace_id=None, - parent_span_id=None, - tracer=False, - streaming=fake_streaming, - ) - result = await emitter.auto_send_turn(turn) - - assert called == [("temporal prompt", "old-sid")] - assert "Hello from Temporal Claude Code" in result.final_text diff --git a/examples/tutorials/10_async/10_temporal/150_codex/.dockerignore b/examples/tutorials/10_async/10_temporal/150_codex/.dockerignore deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/10_async/10_temporal/150_codex/.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/150_codex/Dockerfile b/examples/tutorials/10_async/10_temporal/150_codex/Dockerfile deleted file mode 100644 index e861c7f33..000000000 --- a/examples/tutorials/10_async/10_temporal/150_codex/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 the codex CLI: the worker spawns `codex exec --json`, so the binary -# must be present on PATH in the image. -RUN npm install -g @openai/codex - -RUN uv pip install --system --upgrade pip setuptools wheel - -ENV UV_HTTP_TIMEOUT=1000 - -COPY 10_async/10_temporal/150_codex/pyproject.toml /app/150_codex/pyproject.toml -COPY 10_async/10_temporal/150_codex/README.md /app/150_codex/README.md - -WORKDIR /app/150_codex - -COPY 10_async/10_temporal/150_codex/project /app/150_codex/project -COPY 10_async/10_temporal/150_codex/tests /app/150_codex/tests -COPY test_utils /app/test_utils - -RUN uv pip install --system .[dev] - -ENV PYTHONPATH=/app -ENV AGENT_NAME=at150-codex - -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] - -# When deploying the worker, replace CMD with: -# CMD ["python", "-m", "project.run_worker"] diff --git a/examples/tutorials/10_async/10_temporal/150_codex/README.md b/examples/tutorials/10_async/10_temporal/150_codex/README.md deleted file mode 100644 index 498b81374..000000000 --- a/examples/tutorials/10_async/10_temporal/150_codex/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# 150_codex (Temporal) - -Tutorial agent demonstrating the `convert_codex_to_agentex_events` tap, -`CodexTurn`, and `UnifiedEmitter` for a **Temporal-durable** async ACP agent. - -## What this tutorial shows - -- Spawning `codex exec --json` as a **local asyncio subprocess** (no Scale sandbox) - inside a Temporal workflow signal handler. -- Wrapping the stdout line stream in a `CodexTurn`. -- Delivering every canonical `StreamTaskMessage*` event to Redis via - `UnifiedEmitter.auto_send_turn`, passing `created_at=workflow.now()` for - deterministic Temporal replay timestamps. -- Keeping the codex thread ID on the workflow instance (durable across crashes - without an external `adk.state` round-trip). - -> **Production isolation note:** A tutorial agent runs the Codex CLI locally. -> Production-grade isolation (Scale sandbox, secret injection, MCP configuration) -> is handled by the golden agent at -> `teams/sgp/agents/golden_agent/project/harness/providers/codex.py`. - -> **Temporal determinism note:** Subprocess spawning happens inside -> `@workflow.signal` handler bodies. Temporal does NOT replay signal handler -> bodies (only `@workflow.run` is subject to replay constraints), so this is -> safe. A production agent would wrap the subprocess in a Temporal activity for -> full durability and retry semantics. - -## Live runs - -Live runs require: -1. The `codex` CLI on PATH: `npm install -g @openai/codex` -2. `OPENAI_API_KEY` set in the environment. -3. A running Temporal server. - -## Running offline unit tests - -```bash -cd /path/to/scale-agentex-python -uv run --all-packages --all-extras pytest examples/tutorials/10_async/10_temporal/150_codex/tests/test_agent.py -q -``` - -## Running live integration tests - -```bash -export CODEX_LIVE_TESTS=1 -export OPENAI_API_KEY=sk-... -pytest tests/test_agent.py -v -``` diff --git a/examples/tutorials/10_async/10_temporal/150_codex/conftest.py b/examples/tutorials/10_async/10_temporal/150_codex/conftest.py deleted file mode 100644 index 6370f278d..000000000 --- a/examples/tutorials/10_async/10_temporal/150_codex/conftest.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Add the agent's project root to sys.path so ``import project`` works. - -Also sets minimal environment variables so FastACP, tracing, and the -Temporal workflow module can be imported without a running server. -""" - -import os -import sys - -sys.path.insert(0, os.path.dirname(__file__)) - -# AGENT_NAME must match the manifest's agent name: the live test queries the -# server by this name, and project.workflow reads it at import time. -os.environ.setdefault("AGENT_NAME", "at150-codex") -os.environ.setdefault("ACP_URL", "http://localhost:8000") -os.environ.setdefault("WORKFLOW_NAME", "at150-codex") -os.environ.setdefault("WORKFLOW_TASK_QUEUE", "at150_codex_queue") diff --git a/examples/tutorials/10_async/10_temporal/150_codex/manifest.yaml b/examples/tutorials/10_async/10_temporal/150_codex/manifest.yaml deleted file mode 100644 index d64bdfad0..000000000 --- a/examples/tutorials/10_async/10_temporal/150_codex/manifest.yaml +++ /dev/null @@ -1,62 +0,0 @@ -build: - context: - root: ../../../ - include_paths: - - 10_async/10_temporal/150_codex - - test_utils - dockerfile: 10_async/10_temporal/150_codex/Dockerfile - dockerignore: 10_async/10_temporal/150_codex/.dockerignore - -local_development: - agent: - port: 8000 - host_address: host.docker.internal - paths: - acp: project/acp.py - worker: project/run_worker.py - -agent: - acp_type: async - name: at150-codex - description: Temporal tutorial agent driving the unified harness surface via local codex CLI subprocess - - temporal: - enabled: true - workflows: - - name: at150-codex - queue_name: at150_codex_queue - - credentials: - - env_var_name: OPENAI_API_KEY - secret_name: openai-api-key - secret_key: api-key - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - - env_var_name: SGP_API_KEY - secret_name: sgp-api-key - secret_key: api-key - - env_var_name: SGP_ACCOUNT_ID - secret_name: sgp-account-id - secret_key: account-id - - env_var_name: SGP_CLIENT_BASE_URL - secret_name: sgp-client-base-url - secret_key: url - -deployment: - image: - repository: "" - tag: "latest" - - global: - agent: - name: "at150-codex" - description: "Temporal tutorial agent driving the unified harness surface via local codex CLI subprocess" - replicaCount: 1 - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" diff --git a/examples/tutorials/10_async/10_temporal/150_codex/project/__init__.py b/examples/tutorials/10_async/10_temporal/150_codex/project/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/10_async/10_temporal/150_codex/project/acp.py b/examples/tutorials/10_async/10_temporal/150_codex/project/acp.py deleted file mode 100644 index 39a81dde9..000000000 --- a/examples/tutorials/10_async/10_temporal/150_codex/project/acp.py +++ /dev/null @@ -1,32 +0,0 @@ -"""ACP server for the Temporal Codex harness tutorial. - -This file is intentionally thin. When ``acp_type="async"`` is combined with -``TemporalACPConfig(type="temporal", ...)``, FastACP auto-wires: - - HTTP task/create -> @workflow.run on the workflow class - HTTP task/event/send -> @workflow.signal(SignalName.RECEIVE_EVENT) - HTTP task/cancel -> workflow cancellation via the Temporal client - -so we don't define any handlers here. The actual agent code lives in -``project/workflow.py`` and is executed by the Temporal worker -(``project/run_worker.py``), not by this HTTP process. -""" - -from __future__ import annotations - -import os - -from dotenv import load_dotenv - -load_dotenv() - -from agentex.lib.types.fastacp import TemporalACPConfig -from agentex.lib.sdk.fastacp.fastacp import FastACP - -acp = FastACP.create( - acp_type="async", - config=TemporalACPConfig( - type="temporal", - temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), - ), -) diff --git a/examples/tutorials/10_async/10_temporal/150_codex/project/activities.py b/examples/tutorials/10_async/10_temporal/150_codex/project/activities.py deleted file mode 100644 index 363347635..000000000 --- a/examples/tutorials/10_async/10_temporal/150_codex/project/activities.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Temporal activity for the Codex harness tutorial. - -Subprocess spawning (and any other I/O) must run inside a Temporal *activity*, -not in workflow code. Temporal runs workflow + signal-handler bodies on a -deterministic sandbox event loop that does not implement ``subprocess_exec`` -(or threads / sockets), so spawning ``codex exec`` directly in the signal -handler raises ``NotImplementedError``. This activity runs codex, drives the -``CodexTurn`` through ``UnifiedEmitter.auto_send_turn`` (the async Redis push -path), and returns the turn result to the workflow. - -The ``_spawn_codex`` / ``_process_stdout`` seams are injectable: offline tests -replace them with fakes that yield pre-recorded event lines so no real CLI -runs. -""" - -from __future__ import annotations - -import os -import codecs -import asyncio -from typing import Any -from datetime import datetime -from collections.abc import AsyncIterator - -from temporalio import activity - -from agentex.lib.adk import CodexTurn -from agentex.lib.core.harness import UnifiedEmitter -from agentex.lib.utils.logging import make_logger -from agentex.lib.utils.model_utils import BaseModel - -logger = make_logger(__name__) - -RUN_CODEX_TURN_ACTIVITY = "run_codex_turn" - - -class RunCodexTurnParams(BaseModel): - """Arguments for one codex turn run inside an activity.""" - - task_id: str - prompt: str - model: str - trace_id: str | None = None - parent_span_id: str | None = None - thread_id: str | None = None - created_at: datetime | None = None - - -class RunCodexTurnResult(BaseModel): - """Result returned from the activity to the workflow.""" - - final_text: str - session_id: str | None = None - model: str | None = None - - -async def _spawn_codex( - model: str, - thread_id: str | None = None, -) -> asyncio.subprocess.Process: - """Spawn ``codex exec --json`` locally and return the live process. - - Injection seam: tests replace this function with a fake that returns a - mock process whose stdout yields pre-recorded event lines. - - The caller writes the prompt to stdin after the process starts, then - closes stdin so codex knows input is complete. - """ - base_flags = [ - "--json", - "--skip-git-repo-check", - "--dangerously-bypass-approvals-and-sandbox", - "--model", - model, - ] - - if thread_id: - cmd = ["codex", "exec", *base_flags, "resume", thread_id, "-"] - else: - cmd = ["codex", "exec", *base_flags, "-"] - - return await asyncio.create_subprocess_exec( - *cmd, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - # Discard stderr: codex --json writes events to stdout; its stderr is - # progress/debug noise. Capturing it with PIPE but never reading it - # would deadlock once codex fills the OS pipe buffer (~64 KB). - stderr=asyncio.subprocess.DEVNULL, - env={**os.environ}, - ) - - -async def _process_stdout(process: asyncio.subprocess.Process) -> AsyncIterator[str]: - """Yield newline-delimited JSON lines from the process stdout. - - Uses an incremental UTF-8 decoder so a multibyte character split across two - 4 KB reads is decoded correctly instead of being corrupted at the boundary. - """ - assert process.stdout is not None - decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") - buffer = "" - while True: - chunk = await process.stdout.read(4096) - if not chunk: - break - buffer += decoder.decode(chunk) - while "\n" in buffer: - line, buffer = buffer.split("\n", 1) - line = line.strip() - if line: - yield line - buffer += decoder.decode(b"", final=True) - if buffer.strip(): - yield buffer.strip() - - -@activity.defn(name=RUN_CODEX_TURN_ACTIVITY) -async def run_codex_turn(params: RunCodexTurnParams) -> dict[str, Any]: - """Run one codex turn end-to-end and stream events to the task. - - Runs in an activity (real asyncio loop) so subprocess I/O is permitted. - """ - process = await _spawn_codex(params.model, thread_id=params.thread_id) - - assert process.stdin is not None - process.stdin.write(params.prompt.encode("utf-8")) - await process.stdin.drain() - process.stdin.close() - - turn = CodexTurn(events=_process_stdout(process), model=params.model) - emitter = UnifiedEmitter( - task_id=params.task_id, - trace_id=params.trace_id, - parent_span_id=params.parent_span_id, - ) - result = await emitter.auto_send_turn(turn, created_at=params.created_at) - - await process.wait() - - return RunCodexTurnResult( - final_text=result.final_text, - session_id=turn.session_id, - model=turn.usage().model, - ).model_dump() diff --git a/examples/tutorials/10_async/10_temporal/150_codex/project/run_worker.py b/examples/tutorials/10_async/10_temporal/150_codex/project/run_worker.py deleted file mode 100644 index b8972806b..000000000 --- a/examples/tutorials/10_async/10_temporal/150_codex/project/run_worker.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Temporal worker for the Codex harness tutorial. - -Run as a separate long-lived process alongside the ACP HTTP server. The -worker polls Temporal for workflow + activity tasks and executes them. - -The codex CLI subprocess runs in the ``run_codex_turn`` activity (registered -below alongside the built-in Agentex activities), because subprocess I/O is not -permitted on the Temporal workflow event loop. -""" - -import asyncio - -from project.workflow import AtHarnessCodexWorkflow -from project.activities import run_codex_turn -from agentex.lib.utils.debug import setup_debug_if_enabled -from agentex.lib.utils.logging import make_logger -from agentex.lib.environment_variables import EnvironmentVariables -from agentex.lib.core.temporal.activities import get_all_activities -from agentex.lib.core.temporal.workers.worker import AgentexWorker - -environment_variables = EnvironmentVariables.refresh() -logger = make_logger(__name__) - - -async def main(): - setup_debug_if_enabled() - - task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE - if task_queue_name is None: - raise ValueError("WORKFLOW_TASK_QUEUE is not set") - - worker = AgentexWorker(task_queue=task_queue_name) - - await worker.run( - activities=[run_codex_turn, *get_all_activities()], - workflow=AtHarnessCodexWorkflow, - ) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/tutorials/10_async/10_temporal/150_codex/project/workflow.py b/examples/tutorials/10_async/10_temporal/150_codex/project/workflow.py deleted file mode 100644 index 1970b478f..000000000 --- a/examples/tutorials/10_async/10_temporal/150_codex/project/workflow.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Temporal workflow for the Codex harness tutorial. - -Demonstrates the ``convert_codex_to_agentex_events`` tap + ``CodexTurn`` + -``UnifiedEmitter`` for a Temporal-durable ACP agent. - -KEY CONCEPTS DEMONSTRATED: -- Running ``codex exec --json`` in the ``run_codex_turn`` activity. Subprocess - I/O is not permitted on the Temporal workflow event loop (the deterministic - sandbox loop does not implement ``subprocess_exec``), so the signal handler - delegates the turn to an activity, which also gets Temporal's retry + timeout - guarantees. -- Wrapping the stdout line stream in a ``CodexTurn`` (inside the activity). -- Delivering events via ``UnifiedEmitter.auto_send_turn``, which pushes - ``StreamTaskMessage*`` events to Redis so the UI sees tokens in real time. -- Passing ``created_at=workflow.now()`` for deterministic timestamps under - Temporal replay (required for Temporal-safe delivery). -- Persisting the codex thread ID on the workflow instance itself โ€” Temporal's - workflow state is durable, so no external ``adk.state`` round-trip is needed. -""" - -from __future__ import annotations - -import os -from datetime import timedelta - -from temporalio import workflow - -from agentex.lib import adk -from agentex.lib.types.acp import SendEventParams, CreateTaskParams -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.types.text_content import TextContent -from agentex.lib.environment_variables import EnvironmentVariables -from agentex.lib.core.temporal.types.workflow import SignalName -from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config - -with workflow.unsafe.imports_passed_through(): - from project.activities import RunCodexTurnParams, run_codex_turn - -add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SGP_API_KEY", ""), - sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - ) -) - -environment_variables = EnvironmentVariables.refresh() - -if environment_variables.WORKFLOW_NAME is None: - raise ValueError("Environment variable WORKFLOW_NAME is not set") -if environment_variables.AGENT_NAME is None: - raise ValueError("Environment variable AGENT_NAME is not set") - -logger = make_logger(__name__) - -MODEL = os.environ.get("CODEX_MODEL", "o4-mini") - - -@workflow.defn(name=environment_variables.WORKFLOW_NAME) -class AtHarnessCodexWorkflow(BaseWorkflow): - """Long-running Temporal workflow that runs codex exec for each turn. - - Conversation state (codex thread ID + turn counter) is kept on the - workflow instance. Temporal's durable replay reconstructs this state if - the worker crashes, so no external ``adk.state`` round-trip is needed. - """ - - def __init__(self): - super().__init__(display_name=environment_variables.AGENT_NAME) - self._complete_task = False - self._turn_number = 0 - self._codex_thread_id: str | None = None - - @workflow.signal(name=SignalName.RECEIVE_EVENT) - async def on_task_event_send(self, params: SendEventParams) -> None: - """Handle a new user message: spawn codex, stream events via UnifiedEmitter.""" - logger.info("Received task event: %s", params.task.id) - self._turn_number += 1 - - await adk.messages.create(task_id=params.task.id, content=params.event.content) - - user_message = params.event.content.content - - async with adk.tracing.span( - trace_id=params.task.id, - task_id=params.task.id, - name=f"Turn {self._turn_number}", - input={"message": user_message}, - ) as span: - # Delegate the subprocess turn to an activity: subprocess I/O is not - # permitted on the Temporal workflow event loop. The activity streams - # events to the task and returns the final text + codex thread id. - # workflow.now() gives a deterministic timestamp under replay. - result = await workflow.execute_activity( - run_codex_turn, - RunCodexTurnParams( - task_id=params.task.id, - prompt=user_message, - model=MODEL, - trace_id=params.task.id, - parent_span_id=span.id if span else None, - thread_id=self._codex_thread_id, - created_at=workflow.now(), - ), - start_to_close_timeout=timedelta(minutes=5), - ) - - # Persist the codex thread id so the next turn resumes the session. - session_id = result.get("session_id") - if session_id: - self._codex_thread_id = session_id - - if span: - span.output = { - "final_text": result.get("final_text"), - "model": result.get("model"), - } - - @workflow.run - async def on_task_create(self, params: CreateTaskParams) -> str: - """Workflow entry point โ€” keep the conversation alive for incoming signals.""" - logger.info("Task created: %s", params.task.id) - - await adk.messages.create( - task_id=params.task.id, - content=TextContent( - author="agent", - content=( - f"Task initialized.\n" - f"Send me a message and I'll run codex (local subprocess) " - f"to answer, streaming events via the unified harness surface." - ), - ), - ) - - await workflow.wait_condition(lambda: self._complete_task, timeout=None) - return "Task completed" - - @workflow.signal - async def complete_task_signal(self) -> None: - """Graceful workflow shutdown signal.""" - logger.info("Received complete_task signal") - self._complete_task = True diff --git a/examples/tutorials/10_async/10_temporal/150_codex/pyproject.toml b/examples/tutorials/10_async/10_temporal/150_codex/pyproject.toml deleted file mode 100644 index 7e1d6250f..000000000 --- a/examples/tutorials/10_async/10_temporal/150_codex/pyproject.toml +++ /dev/null @@ -1,40 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "at150-codex" -version = "0.1.0" -description = "Temporal tutorial agent driving the unified harness surface via local codex CLI subprocess" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "agentex-sdk", - "scale-gp", - "temporalio>=1.18.2", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "httpx", - "black", - "isort", - "flake8", - "debugpy>=1.8.15", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] - -[tool.black] -line-length = 88 -target-version = ['py312'] - -[tool.isort] -profile = "black" -line_length = 88 - -[tool.pytest.ini_options] -asyncio_mode = "auto" diff --git a/examples/tutorials/10_async/10_temporal/150_codex/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/150_codex/tests/test_agent.py deleted file mode 100644 index fa6c66083..000000000 --- a/examples/tutorials/10_async/10_temporal/150_codex/tests/test_agent.py +++ /dev/null @@ -1,275 +0,0 @@ -"""Tests for the Temporal Codex harness tutorial agent. - -LIVE tests (``TestLiveCodexAgent``): - - Require the ``codex`` CLI on PATH, ``OPENAI_API_KEY``, and a running - Temporal + Agentex server. - - Skipped automatically when ``CODEX_LIVE_TESTS`` is not set to ``1``. - -OFFLINE unit tests (``TestOfflineCodexWorkflow``): - - Inject a fake async iterator of pre-recorded codex event lines. - - Assert the signal handler drives ``UnifiedEmitter.auto_send_turn`` and - captures the codex thread ID on the workflow instance. - - Always run. -""" - -from __future__ import annotations - -import os -import json -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -# --------------------------------------------------------------------------- -# Shared helpers -# --------------------------------------------------------------------------- - -SAMPLE_EVENTS: list[dict[str, Any]] = [ - {"type": "thread.started", "thread_id": "thread-temporal-1"}, - {"type": "turn.started"}, - { - "type": "item.started", - "item": {"id": "msg-t1", "type": "agent_message", "text": "Hello"}, - }, - { - "type": "item.completed", - "item": {"id": "msg-t1", "type": "agent_message", "text": "Hello from Temporal!"}, - }, - { - "type": "turn.completed", - "usage": {"input_tokens": 6, "output_tokens": 3, "total_tokens": 9}, - }, -] - - -async def _fake_event_stream(): - """Async iterator of pre-recorded codex event JSON lines (no subprocess).""" - for evt in SAMPLE_EVENTS: - yield json.dumps(evt) - - -class _FakeSpan: - id = "span-temporal-1" - output: Any = None - - async def __aenter__(self): - return self - - async def __aexit__(self, *a): - pass - - -class TestOfflineCodexWorkflow: - """Unit tests that run without a real codex CLI, Temporal, or network.""" - - @pytest.mark.asyncio - async def test_codex_turn_usage_with_temporal_events(self): - """CodexTurn.usage() is correct after exhausting the temporal sample events.""" - from agentex.lib.adk import CodexTurn - - turn = CodexTurn(events=_fake_event_stream(), model="o4-mini") - - _ = [e async for e in turn.events] - - usage = turn.usage() - assert usage.input_tokens == 6 - assert usage.output_tokens == 3 - assert usage.model == "o4-mini" - - @pytest.mark.asyncio - async def test_unified_emitter_auto_send_with_created_at(self): - """UnifiedEmitter.auto_send_turn accepts created_at=None without error.""" - from agentex.lib.adk import CodexTurn - from agentex.lib.core.harness import UnifiedEmitter - from agentex.types.task_message import TaskMessage - from agentex.types.text_content import TextContent - - turn = CodexTurn(events=_fake_event_stream(), model="o4-mini") - - real_task_msg = TaskMessage( - id="msg-fake", - task_id="t", - content=TextContent(type="text", author="agent", content=""), - ) - - fake_streaming = MagicMock() - fake_ctx = AsyncMock() - fake_ctx.__aenter__ = AsyncMock(return_value=fake_ctx) - fake_ctx.__aexit__ = AsyncMock(return_value=False) - fake_ctx.stream_update = AsyncMock(return_value=MagicMock()) - fake_ctx.close = AsyncMock() - fake_ctx.task_message = real_task_msg - fake_streaming.streaming_task_message_context = MagicMock(return_value=fake_ctx) - - emitter = UnifiedEmitter( - task_id="t", - trace_id=None, - parent_span_id=None, - streaming=fake_streaming, - ) - - result = await emitter.auto_send_turn(turn, created_at=None) - assert result is not None - - @pytest.mark.asyncio - async def test_thread_id_captured_after_exhausted_stream(self): - """CodexTurn._result captures the thread_id from thread.started.""" - from agentex.lib.adk import CodexTurn - - turn = CodexTurn(events=_fake_event_stream(), model="o4-mini") - _ = [e async for e in turn.events] - - assert turn._result is not None - assert turn._result["session_id"] == "thread-temporal-1" - - @pytest.mark.asyncio - async def test_signal_handler_delegates_to_activity_and_captures_thread_id(self): - """Signal handler runs the turn via execute_activity, increments the turn - counter, and captures the codex thread ID returned by the activity.""" - captured: dict[str, Any] = {} - - async def _fake_execute_activity(_activity, params, **_kw): - captured["params"] = params - return { - "session_id": "thread-temporal-1", - "final_text": "Hello from Temporal!", - "model": "o4-mini", - } - - with patch("project.workflow.adk.messages.create", new=AsyncMock()), patch( - "project.workflow.adk.tracing.span" - ) as mock_span, patch( - "project.workflow.workflow.execute_activity", new=_fake_execute_activity - ), patch("project.workflow.workflow.now", return_value=None): - mock_span.return_value = _FakeSpan() - - from project.workflow import AtHarnessCodexWorkflow - - wf = AtHarnessCodexWorkflow.__new__(AtHarnessCodexWorkflow) - wf._turn_number = 0 - wf._codex_thread_id = None - wf._complete_task = False - wf._display_name = "test" - - params = MagicMock() - params.task.id = "task-temporal-offline-1" - params.event.content.content = "say hello temporal" - - await wf.on_task_event_send(params) - - assert wf._turn_number == 1 - assert wf._codex_thread_id == "thread-temporal-1" - assert captured["params"].prompt == "say hello temporal" - assert captured["params"].thread_id is None - - @pytest.mark.asyncio - async def test_run_codex_turn_activity_streams_and_returns_thread_id(self): - """The run_codex_turn activity drives the turn and returns the thread id.""" - from agentex.lib.core.harness import UnifiedEmitter - - async def _fake_spawn(model, thread_id=None): # noqa: ARG001 - fake_stdin = MagicMock() - fake_stdin.write = MagicMock() - fake_stdin.drain = AsyncMock() - fake_stdin.close = MagicMock() - proc = MagicMock() - proc.stdin = fake_stdin - proc.wait = AsyncMock(return_value=0) - return proc - - async def _fake_process_stdout(_process): # noqa: ARG001 - for evt in SAMPLE_EVENTS: - yield json.dumps(evt) - - class _FakeTurnResult: - final_text = "Hello from Temporal!" - - async def _auto_send(_self, turn, *_a, **_kw): - async for _ in turn.events: - pass - return _FakeTurnResult() - - with patch("project.activities._spawn_codex", new=_fake_spawn), patch( - "project.activities._process_stdout", new=_fake_process_stdout - ), patch.object(UnifiedEmitter, "auto_send_turn", new=_auto_send): - from project.activities import RunCodexTurnParams, run_codex_turn - - result = await run_codex_turn( - RunCodexTurnParams( - task_id="task-temporal-offline-1", - prompt="say hello temporal", - model="o4-mini", - ) - ) - - assert result["session_id"] == "thread-temporal-1" - assert result["final_text"] == "Hello from Temporal!" - - -# --------------------------------------------------------------------------- -# Live tests -# --------------------------------------------------------------------------- - -LIVE = os.environ.get("CODEX_LIVE_TESTS", "") == "1" -AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") -AGENT_NAME = os.environ.get("AGENT_NAME", "at150-codex") - - -@pytest.mark.skipif( - not LIVE, - reason="Set CODEX_LIVE_TESTS=1 and ensure codex CLI + OPENAI_API_KEY + Temporal are available", -) -class TestLiveCodexAgent: - """End-to-end tests that require the real codex CLI, Temporal, and Agentex server.""" - - @pytest.fixture - def client(self): - from agentex import Agentex - - return Agentex(base_url=AGENTEX_API_BASE_URL) - - @pytest.fixture - def agent_id(self, client): - for agent in client.agents.list(): - if agent.name == AGENT_NAME: - return agent.id - raise ValueError(f"Agent {AGENT_NAME!r} not found.") - - def test_send_simple_message(self, client, agent_id: str): - """Temporal agents process events out of band, so create a task, send an - event, and poll the task's messages for the agent's response.""" - import time - import uuid - - from agentex.types import TextContentParam - from agentex.types.agent_rpc_params import ParamsSendEventRequest, ParamsCreateTaskRequest - - task = client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)).result - assert task is not None - - client.agents.send_event( - agent_id=agent_id, - params=ParamsSendEventRequest( - task_id=task.id, - content=TextContentParam( - author="user", - content="What is 5+5? Reply with just the number.", - type="text", - ), - ), - ) - - deadline = time.monotonic() + 90 - while time.monotonic() < deadline: - msgs = client.messages.list(task_id=task.id) - agent_msgs = [m for m in msgs if getattr(m.content, "author", None) == "agent"] - response_msgs = [ - m for m in agent_msgs if "Task initialized" not in str(getattr(m.content, "content", "")) - ] - if response_msgs: - assert len(response_msgs) >= 1 - return - time.sleep(3) - - raise AssertionError("No agent response received within 90 s") diff --git a/examples/tutorials/README.md b/examples/tutorials/README.md 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 ead49363e..000000000 --- a/examples/tutorials/run_agent_test.sh +++ /dev/null @@ -1,469 +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 - - # uv workspace builds both wheels into the root dist/ (slim + heavy ADK). - # We need both: heavy pins agentex-client which isn't on PyPI yet, - # so uv must resolve both from local wheels rather than the registry. - local heavy_wheel=$(ls /home/runner/work/*/*/dist/agentex_sdk-*.whl 2>/dev/null | head -n1) - local slim_wheel=$(ls /home/runner/work/*/*/dist/agentex_client-*.whl 2>/dev/null | head -n1) - if [[ -z "$heavy_wheel" ]]; then - echo -e "${RED}โŒ No built heavy wheel found in dist/agentex_sdk-*.whl${NC}" - echo -e "${YELLOW}๐Ÿ’ก Build it first: uv build --all-packages --wheel${NC}" - cd "$original_dir" - return 1 - fi - if [[ -z "$slim_wheel" ]]; then - echo -e "${RED}โŒ No built slim wheel found in dist/agentex_client-*.whl${NC}" - echo -e "${YELLOW}๐Ÿ’ก Build it first: uv build --wheel${NC}" - cd "$original_dir" - return 1 - fi - - # Pass both wheels so the local heavy resolves its slim dep locally - uv run --with "$heavy_wheel" --with "$slim_wheel" agentex agents run --manifest "$manifest_path" > "$logfile" 2>&1 & - else - uv run agentex agents run --manifest manifest.yaml > "$logfile" 2>&1 & - fi - local pid=$! - - # Return to original directory - cd "$original_dir" - - echo "$pid" > "/tmp/agentex-${name}.pid" - echo -e "${GREEN}โœ… ${name} agent started (PID: $pid, logs: $logfile)${NC}" - - # Wait for agent to be ready - if ! wait_for_agent_ready "$name"; then - kill -9 $pid 2>/dev/null - return 1 - fi - - return 0 -} - -# Helper function to view agent container logs -view_agent_logs() { - local tutorial_path=$1 - - # If tutorial path is provided, view logs for that specific tutorial - if [[ -n "$tutorial_path" ]]; then - local name=$(basename "$tutorial_path") - local logfile="/tmp/agentex-${name}.log" - - echo -e "${YELLOW}๐Ÿ“‹ Viewing logs for ${name}...${NC}" - echo -e "${YELLOW}Log file: $logfile${NC}" - echo "" - - if [[ ! -f "$logfile" ]]; then - echo -e "${RED}โŒ Log file not found: $logfile${NC}" - return 1 - fi - - # Display the logs - tail -f "$logfile" - else - # No specific tutorial, find the most recent log file - local latest_log=$(ls -t /tmp/agentex-*.log 2>/dev/null | head -1) - - if [[ -z "$latest_log" ]]; then - echo -e "${RED}โŒ No agent log files found in /tmp/agentex-*.log${NC}" - echo -e "${YELLOW}Available log files:${NC}" - ls -lht /tmp/agentex-*.log 2>/dev/null || echo " (none)" - return 1 - fi - - echo -e "${YELLOW}๐Ÿ“‹ Viewing most recent agent logs...${NC}" - echo -e "${YELLOW}Log file: $latest_log${NC}" - echo "" - - # Display the logs - tail -f "$latest_log" - fi -} - -# Function to stop agent -stop_agent() { - local tutorial_path=$1 - local name=$(basename "$tutorial_path") - local pidfile="/tmp/agentex-${name}.pid" - local logfile="/tmp/agentex-${name}.log" - - echo -e "${YELLOW}๐Ÿ›‘ Stopping ${name} agent...${NC}" - - # Check if PID file exists - if [[ ! -f "$pidfile" ]]; then - echo -e "${YELLOW}โš ๏ธ No PID file found for ${name} agent${NC}" - return 0 - fi - - # Read PID from file - local pid=$(cat "$pidfile") - - # Check if process is running and kill it - if kill -0 "$pid" 2>/dev/null; then - echo -e "${YELLOW}Stopping ${name} agent (PID: $pid)${NC}" - kill "$pid" 2>/dev/null || true - rm -f "$pidfile" - else - echo -e "${YELLOW}โš ๏ธ ${name} agent was not running${NC}" - rm -f "$pidfile" - fi - - echo -e "${GREEN}โœ… ${name} agent stopped${NC}" - echo -e "${YELLOW}Logs available at: $logfile${NC}" - - return 0 -} - - -# Function to run test for a tutorial -run_test() { - local tutorial_path=$1 - local name=$(basename "$tutorial_path") - - echo -e "${YELLOW}๐Ÿงช Running tests for ${name}...${NC}" - - # Check if tutorial directory exists - if [[ ! -d "$tutorial_path" ]]; then - echo -e "${RED}โŒ Tutorial directory not found: $tutorial_path${NC}" - return 1 - fi - - # Check if test file exists - if [[ ! -f "$tutorial_path/tests/test_agent.py" ]]; then - echo -e "${RED}โŒ Test file not found: $tutorial_path/tests/test_agent.py${NC}" - return 1 - fi - - # Save current directory - local original_dir="$PWD" - - # Change to tutorial directory - cd "$tutorial_path" || return 1 - - - # Run the tests with retry mechanism. - # - # pytest is brought in explicitly via --with: the tutorials only list it - # under an optional `dev` extra (which `uv run` does not install), and it - # used to be pulled in transitively by agentex-sdk's runtime deps. Once - # agentex-sdk 0.11.5 dropped pytest as a runtime dep, `uv run pytest` could - # no longer find it ("Failed to spawn: pytest"). Requesting it directly is - # robust across all tutorials regardless of how each declares test deps. - local -a pytest_cmd=("uv" "run" "--with" "pytest" "--with" "pytest-asyncio" "pytest") - if [ "$BUILD_CLI" = true ]; then - local heavy_wheel slim_wheel - heavy_wheel=$(ls /home/runner/work/*/*/dist/agentex_sdk-*.whl 2>/dev/null | head -n1) - if [[ -z "$heavy_wheel" ]]; then - heavy_wheel=$(ls "${SCRIPT_DIR}"/../../dist/agentex_sdk-*.whl 2>/dev/null | head -n1) - fi - slim_wheel=$(ls /home/runner/work/*/*/dist/agentex_client-*.whl 2>/dev/null | head -n1) - if [[ -z "$slim_wheel" ]]; then - slim_wheel=$(ls "${SCRIPT_DIR}"/../../dist/agentex_client-*.whl 2>/dev/null | head -n1) - fi - if [[ -z "$heavy_wheel" || -z "$slim_wheel" ]]; then - echo -e "${RED}โŒ BUILD_CLI=true but a wheel is missing (heavy='${heavy_wheel}' slim='${slim_wheel}'); refusing to test against the pre-installed SDK${NC}" - return 1 - fi - pytest_cmd=("uv" "run" "--with" "$heavy_wheel" "--with" "$slim_wheel" "--with" "pytest" "--with" "pytest-asyncio" "pytest") - fi - - local max_retries=5 - local retry_count=0 - local exit_code=1 - - while [ $retry_count -lt $max_retries ]; do - if [ $retry_count -gt 0 ]; then - echo -e "${YELLOW}๐Ÿ”„ Retrying tests (attempt $((retry_count + 1))/$max_retries)...${NC}" - fi - - # Stream pytest output directly in real-time - "${pytest_cmd[@]}" tests/test_agent.py -v -s - exit_code=$? - - if [ $exit_code -eq 0 ]; then - break - else - retry_count=$((retry_count + 1)) - if [ $retry_count -lt $max_retries ]; then - sleep 5 - fi - fi - done - - # Return to original directory - cd "$original_dir" - - if [ $exit_code -eq 0 ]; then - echo -e "${GREEN}โœ… Tests passed for ${name}${NC}" - return 0 - else - echo -e "${RED}โŒ Tests failed for ${name}${NC}" - return 1 - fi -} - -# Function to execute test flow for a single tutorial -execute_tutorial_test() { - local tutorial=$1 - - echo "" - echo "================================================================================" - echo "Testing: $tutorial" - echo "================================================================================" - - # Start the agent - if ! start_agent "$tutorial"; then - echo -e "${RED}โŒ FAILED to start agent: $tutorial${NC}" - return 1 - fi - - # Run the tests - local test_passed=false - if run_test "$tutorial"; then - echo -e "${GREEN}โœ… PASSED: $tutorial${NC}" - test_passed=true - else - echo -e "${RED}โŒ FAILED: $tutorial${NC}" - fi - - # Stop the agent - stop_agent "$tutorial" - - echo "" - - if [ "$test_passed" = true ]; then - return 0 - else - return 1 - fi -} - -# Function to check if both built wheels are available -check_built_wheel() { - - # Navigate to the repo root (two levels up from examples/tutorials) - local repo_root="../../" - local original_dir="$PWD" - - cd "$repo_root" || { - echo -e "${RED}โŒ Failed to navigate to repo root${NC}" - return 1 - } - - # Heavy ADK wheel + slim client wheel โ€” we need both because heavy pins - # agentex-client which isn't on PyPI yet. - local heavy_wheel=$(ls /home/runner/work/*/*/dist/agentex_sdk-*.whl 2>/dev/null | head -n1) - local slim_wheel=$(ls /home/runner/work/*/*/dist/agentex_client-*.whl 2>/dev/null | head -n1) - if [[ -z "$heavy_wheel" ]]; then - echo -e "${RED}โŒ No built heavy wheel found in dist/agentex_sdk-*.whl${NC}" - echo -e "${YELLOW}๐Ÿ’ก Build it first: uv build --all-packages --wheel${NC}" - cd "$original_dir" - return 1 - fi - if [[ -z "$slim_wheel" ]]; then - echo -e "${RED}โŒ No built slim wheel found in dist/agentex_client-*.whl${NC}" - echo -e "${YELLOW}๐Ÿ’ก Build it first: uv build --wheel${NC}" - cd "$original_dir" - return 1 - fi - - # Test the heavy wheel by running agentex --help (uses both wheels for resolution) - if ! uv run --with "$heavy_wheel" --with "$slim_wheel" agentex --help >/dev/null 2>&1; then - echo -e "${RED}โŒ Failed to run agentex with built wheels${NC}" - cd "$original_dir" - return 1 - fi - cd "$original_dir" - return 0 -} - - -# Main execution function -main() { - # Handle --view-logs flag - if [ "$VIEW_LOGS" = true ]; then - if [[ -n "$TUTORIAL_PATH" ]]; then - view_agent_logs "$TUTORIAL_PATH" - else - view_agent_logs - fi - exit 0 - fi - # Require tutorial path - if [[ -z "$TUTORIAL_PATH" ]]; then - echo -e "${RED}โŒ Error: Tutorial path is required${NC}" - echo "" - echo "Usage:" - echo " ./run_agent_test.sh # Run single tutorial test" - echo " ./run_agent_test.sh --build-cli # Build CLI from source and run test" - echo " ./run_agent_test.sh --view-logs # View logs for specific tutorial" - echo " ./run_agent_test.sh --view-logs # View most recent agent logs" - echo "" - echo "Examples:" - echo " ./run_agent_test.sh 00_sync/000_hello_acp" - echo " ./run_agent_test.sh --build-cli 00_sync/000_hello_acp" - exit 1 - fi - - echo "================================================================================" - echo "Running Tutorial Test: $TUTORIAL_PATH" - echo "================================================================================" - - # Check prerequisites - check_prerequisites - - echo "" - - # Check built wheel if requested - if [ "$BUILD_CLI" = true ]; then - if ! check_built_wheel; then - echo -e "${RED}โŒ Failed to find or verify built wheel${NC}" - exit 1 - fi - echo "" - fi - - # Execute the single tutorial test - if execute_tutorial_test "$TUTORIAL_PATH"; then - echo "" - echo "================================================================================" - echo -e "${GREEN}๐ŸŽ‰ Test passed for: $TUTORIAL_PATH${NC}" - echo "================================================================================" - exit 0 - else - echo "" - echo "================================================================================" - echo -e "${RED}โŒ Test failed for: $TUTORIAL_PATH${NC}" - echo "================================================================================" - exit 1 - fi -} - -# Run main function -main diff --git a/examples/tutorials/test_utils/async_utils.py b/examples/tutorials/test_utils/async_utils.py 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 4a3fea3f2..8c0cb410d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,7 @@ [project] -# This is the Stainless-generated REST client. The hand-authored ADK -# overlay (formerly `src/agentex/lib/*`) now lives in `adk/` and ships -# as the sibling `agentex-sdk` package โ€” see `adk/pyproject.toml`. name = "agentex-client" version = "0.20.0" -description = "The official Python REST client for the Agentex API" +description = "The official Python library for the agentex API" dynamic = ["readme"] license = "Apache-2.0" authors = [ @@ -12,21 +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", + "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", @@ -42,26 +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", -] - -# The `agentex` CLI entry point ships from the ADK package โ€” see -# `adk/pyproject.toml`. The slim client has no CLI surface. - -[tool.uv.workspace] -# Dev: `uv sync --all-packages` installs both members editably. Shared source -# `src/agentex/lib/` ships only from the heavy (via adk/hatch_build.py). -members = ["adk"] - -[tool.uv.sources] -# Dev-only: resolve the ADK's agentex-client dep to this root package. -# Stripped from published wheels โ€” the heavy wheel still pins the PyPI version. -agentex-client = { workspace = true } [tool.uv] managed = true required-version = ">=0.9" +conflicts = [ + [ + { group = "pydantic-v1" }, + { group = "pydantic-v2" }, + ], +] [dependency-groups] # version pins are in uv.lock @@ -76,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] @@ -95,12 +87,6 @@ include = [ [tool.hatch.build.targets.wheel] packages = ["src/agentex"] -# agentex/lib/* ships from the sibling agentex-sdk package (see adk/pyproject.toml). -# Excluding it here keeps the slim wheel disjoint from the heavy wheel so both -# can install into the same site-packages/agentex/ without file conflicts. -exclude = [ - "src/agentex/lib/**", -] [tool.hatch.build.targets.sdist] # Basically everything except hidden files/directories (such as .github, .devcontainers, .python-version, etc) @@ -135,27 +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", - # Build-time hook; imports hatchling, a build dep absent from the synced env. - "adk/hatch_build.py", ] reportImplicitOverride = true @@ -164,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 @@ -201,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 @@ -218,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 @@ -234,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]] @@ -296,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/release-please-config.json b/release-please-config.json index 7bae5f5a3..52d4c546d 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,28 +1,10 @@ { "packages": { - ".": { - "component": "agentex-client", - "extra-files": [ - "src/agentex/_version.py" - ] - }, - "adk": { - "component": "agentex-sdk" - } + ".": {} }, - "plugins": [ - { - "type": "linked-versions", - "groupName": "agentex", - "components": [ - "agentex-client", - "agentex-sdk" - ] - } - ], "$schema": "https://raw.githubusercontent.com/stainless-api/release-please/main/schemas/config.json", "include-v-in-tag": true, - "include-component-in-tag": true, + "include-component-in-tag": false, "versioning": "prerelease", "prerelease": true, "bump-minor-pre-major": true, @@ -77,5 +59,8 @@ "hidden": true } ], - "release-type": "python" + "release-type": "python", + "extra-files": [ + "src/agentex/_version.py" + ] } \ No newline at end of file diff --git a/requirements-dev.lock b/requirements-dev.lock index ff876c6b5..4a161724a 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -1,510 +1,110 @@ # This file was autogenerated by uv via the following command: -# uv export --all-packages -o requirements-dev.lock --no-hashes +# uv export -o requirements-dev.lock --no-hashes -e . - # via agentex-sdk --e ./adk -aiohappyeyeballs==2.6.1 - # via aiohttp -aiohttp==3.13.3 - # via - # agentex-sdk - # litellm -aiosignal==1.4.0 - # via aiohttp -annotated-doc==0.0.4 - # via fastapi annotated-types==0.7.0 # via pydantic anyio==4.12.1 # via # agentex-client - # claude-agent-sdk # httpx - # httpx2 - # mcp - # openai - # scale-gp - # scale-gp-beta - # sse-starlette - # starlette - # watchfiles -asttokens==3.0.1 - # via stack-data -attrs==25.4.0 - # via - # aiohttp - # jsonschema - # referencing -bytecode==0.17.0 - # via ddtrace +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-client +exceptiongroup==1.3.1 ; python_full_version < '3.11' # via - # agentex-client - # 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-client - # huggingface-hub - # langsmith - # litellm - # mcp - # openai - # pydantic-ai-slim - # pydantic-graph # respx - # scale-gp - # scale-gp-beta -httpx-sse==0.4.3 - # via mcp -httpx2==2.3.0 - # via genai-prices -huggingface-hub==1.13.0 - # via tokenizers idna==3.11 # via # anyio # httpx - # httpx2 - # requests - # yarl importlib-metadata==8.7.1 - # via litellm -iniconfig==2.3.0 +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-client - # fastapi - # genai-prices - # langchain-core - # langsmith - # litellm - # mcp - # openai - # openai-agents - # pydantic-ai-slim - # pydantic-graph - # pydantic-settings - # python-on-whales - # scale-gp - # scale-gp-beta -pydantic-ai-slim==1.105.0 - # via agentex-sdk + # via agentex-client 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-client +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 - # agentex-client - # claude-agent-sdk - # openai - # scale-gp - # scale-gp-beta -sse-starlette==3.4.4 - # via mcp -stack-data==0.6.3 - # via ipython -starlette==1.2.1 - # via - # agentex-sdk - # fastapi - # mcp - # sse-starlette -temporalio==1.27.2 - # via agentex-sdk -tenacity==9.1.4 - # via langchain-core -termcolor==3.3.0 - # via yaspin -tiktoken==0.13.0 - # via litellm -time-machine==3.2.0 -tokenizers==0.23.1 - # via litellm -tqdm==4.67.3 - # via - # huggingface-hub - # openai - # python-on-whales -traitlets==5.15.0 - # via - # ipython - # ipywidgets - # jupyter-core - # matplotlib-inline - # nbformat -truststore==0.10.4 - # via - # httpcore2 - # httpx2 -typer==0.16.1 - # via - # agentex-sdk - # huggingface-hub - # python-on-whales -types-protobuf==6.32.1.20260221 - # via temporalio -types-requests==2.33.0.20260518 - # via openai-agents + # mypy + # pytest typing-extensions==4.15.0 # via # agentex-client - # aiosignal # anyio - # fastapi - # huggingface-hub - # 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/bootstrap b/scripts/bootstrap index 084b88899..5a23841b8 100755 --- a/scripts/bootstrap +++ b/scripts/bootstrap @@ -23,9 +23,8 @@ echo "==> Installing Pythonโ€ฆ" uv python install echo "==> Installing Python dependenciesโ€ฆ" -# --all-packages: install both workspace members (slim client + heavy ADK overlay). -uv sync --all-packages --all-extras +uv sync --all-extras echo "==> Exporting Python dependenciesโ€ฆ" # note: `--no-hashes` is required because of https://github.com/pypa/pip/issues/4995 -uv export --all-packages -o requirements-dev.lock --no-hashes +uv export -o requirements-dev.lock --no-hashes diff --git a/scripts/check-slim-deps b/scripts/check-slim-deps deleted file mode 100755 index d52a333d6..000000000 --- a/scripts/check-slim-deps +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash -# Guardrail: the slim agentex-client must keep exactly its 6 bare-client deps -# โ€” Stainless re-emitting an un-trimmed dashboard dep-list would break the split. - -set -e - -cd "$(dirname "$0")/.." - -python3 - <<'PY' -import re -import sys -import tomllib - -EXPECTED = {"httpx", "pydantic", "typing-extensions", "anyio", "distro", "sniffio"} - - -def norm(name: str) -> str: - return re.sub(r"[-_.]+", "-", name.strip().lower()) - - -with open("pyproject.toml", "rb") as f: - deps = tomllib.load(f)["project"]["dependencies"] - -got = {norm(re.split(r"[<>=!~ \[;]", d, maxsplit=1)[0]) for d in deps} -expected = {norm(n) for n in EXPECTED} - -if got != expected: - print("slim dependency drift in root pyproject.toml!", file=sys.stderr) - print(f" expected ({len(expected)}): {sorted(expected)}", file=sys.stderr) - print(f" got ({len(got)}): {sorted(got)}", file=sys.stderr) - print( - "If Stainless re-added ADK deps, trim the dashboard dep-list " - "(ADK deps belong in adk/pyproject.toml).", - file=sys.stderr, - ) - sys.exit(1) - -print(f"slim deps OK ({len(got)}): {sorted(got)}") -PY diff --git a/scripts/check-wheel-install b/scripts/check-wheel-install deleted file mode 100755 index b80aed2d2..000000000 --- a/scripts/check-wheel-install +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -# Smoke: agentex-client + agentex-sdk must install together into one working -# agentex.* namespace. Builds + installs in a clean temp dir to avoid stale dist/. - -set -euo pipefail - -cd "$(dirname "$0")/.." - -work="$(mktemp -d)" -echo "==> building both wheels into $work/dist" -uv build --all-packages --wheel --out-dir "$work/dist" - -venv="$work/venv" -uv venv "$venv" >/dev/null -echo "==> installing both wheels into a fresh venv" -uv pip install --python "$venv" "$work"/dist/agentex_client-*.whl "$work"/dist/agentex_sdk-*.whl - -echo "==> importing the merged namespace from the installed wheels" -"$venv/bin/python" - <<'PY' -import agentex.lib.adk # ADK overlay โ€” ships in agentex-sdk -from agentex.types import Event # client surface โ€” ships in agentex-client -from agentex.resources import states # client surface that "didn't land" in the incident - -print("agentex namespace OK:", Event.__name__, states.__name__) -PY diff --git a/scripts/lint b/scripts/lint index 0a35b8d8c..294136c2d 100755 --- a/scripts/lint +++ b/scripts/lint @@ -15,5 +15,8 @@ fi echo "==> Running pyright" uv run pyright -p . +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 b01ec2c64..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-packages --all-extras pytest "$@" + 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/config/__init__.py b/src/agentex/config/__init__.py deleted file mode 100644 index 1174c2fdf..000000000 --- a/src/agentex/config/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Deployment & agent configuration shapes for Agentex. - -The modules under `agentex.config.*` are the typed manifest/deployment -configuration models (agent, build, deployment, environment, local-dev) plus -their leaf model deps (credentials, temporal). They depend only on pydantic, -so they are safe to import from a slim REST-only install without the ADK -runtime. - -For back-compat, the same classes are re-exported from their historical -locations under `agentex.lib.sdk.config.*` and -`agentex.lib.types.{agent_configs,credentials}`. The yaml-loading helpers -(`load_environments_config*`) stay in `agentex.lib.sdk.config.environment_config` -so the promoted models remain slim-safe. -""" diff --git a/src/agentex/config/_base.py b/src/agentex/config/_base.py deleted file mode 100644 index 74dafd7a1..000000000 --- a/src/agentex/config/_base.py +++ /dev/null @@ -1,7 +0,0 @@ -from pydantic import BaseModel, ConfigDict - - -class ConfigBaseModel(BaseModel): - # Preserves the config the former agentex.lib.utils.model_utils.BaseModel - # applied; deployment_config's `global` alias relies on populate_by_name. - model_config = ConfigDict(from_attributes=True, populate_by_name=True) diff --git a/src/agentex/config/agent_config.py b/src/agentex/config/agent_config.py deleted file mode 100644 index e06022c0c..000000000 --- a/src/agentex/config/agent_config.py +++ /dev/null @@ -1,64 +0,0 @@ -from __future__ import annotations - -from typing import Any, Literal - -from pydantic import Field - -from agentex.config._base import ConfigBaseModel -from agentex.config.credentials import CredentialMapping -from agentex.config.agent_configs import TemporalConfig, TemporalWorkflowConfig - - -class AgentConfig(ConfigBaseModel): - name: str = Field( - ..., - description="The name of the agent.", - pattern=r"^[a-z0-9-]+$", - ) - acp_type: Literal["sync", "async", "agentic"] = Field(..., description="The type of agent.") - agent_input_type: Literal["text", "json"] | None = Field( - default=None, - description="The type of input the agent accepts." - ) - description: str = Field(..., description="The description of the agent.") - env: dict[str, str] | None = Field( - default=None, description="Environment variables to set directly in the agent deployment" - ) - credentials: list[CredentialMapping | dict[str, Any]] | None = Field( - default=None, - description="List of credential mappings to mount to the agent deployment. Supports both legacy format and new typed credentials.", - ) - temporal: TemporalConfig | None = Field( - default=None, description="Temporal workflow configuration for this agent" - ) - - def is_temporal_agent(self) -> bool: - """Check if this agent uses Temporal workflows""" - # Check temporal config with enabled flag - if self.temporal and self.temporal.enabled: - return True - return False - - def get_temporal_workflow_config(self) -> TemporalWorkflowConfig | None: - """Get temporal workflow configuration, checking both new and legacy formats""" - # Check new workflows list first - if self.temporal and self.temporal.enabled and self.temporal.workflows: - return self.temporal.workflows[0] # Return first workflow for backward compatibility - - # Check legacy single workflow - if self.temporal and self.temporal.enabled and self.temporal.workflow: - return self.temporal.workflow - - return None - - def get_temporal_workflows(self) -> list[TemporalWorkflowConfig]: - """Get all temporal workflow configurations""" - # Check new workflows list first - if self.temporal and self.temporal.enabled and self.temporal.workflows: - return self.temporal.workflows - - # Check legacy single workflow - if self.temporal and self.temporal.enabled and self.temporal.workflow: - return [self.temporal.workflow] - - return [] diff --git a/src/agentex/config/agent_configs.py b/src/agentex/config/agent_configs.py deleted file mode 100644 index e815f3a23..000000000 --- a/src/agentex/config/agent_configs.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import annotations - -from pydantic import Field, BaseModel, field_validator, model_validator - - -class TemporalWorkflowConfig(BaseModel): - """ - Configuration for the temporal workflow that defines the agent. - - Attributes: - name: The name of the temporal workflow that defines the agent. - queue_name: The name of the temporal queue to send tasks to. - """ - - name: str = Field( - ..., description="The name of the temporal workflow that defines the agent." - ) - queue_name: str = Field( - ..., description="The name of the temporal queue to send tasks to." - ) - - -# TODO: Remove this class when we remove the agentex agents create -class TemporalWorkerConfig(BaseModel): - """ - Configuration for temporal worker deployment - - Attributes: - image: The image to use for the temporal worker - workflow: The temporal workflow configuration - """ - - image: str | None = Field( - default=None, description="Image to use for the temporal worker" - ) - workflow: TemporalWorkflowConfig | None = Field( - default=None, - description="Configuration for the temporal workflow that defines the agent. Only required for agents that leverage Temporal.", - ) - - -class TemporalConfig(BaseModel): - """ - Simplified temporal configuration for agents - - Attributes: - enabled: Whether this agent uses Temporal workflows - workflow: The temporal workflow configuration - workflows: The list of temporal workflow configurations - health_check_port: Port for temporal worker health check endpoint - """ - - enabled: bool = Field( - default=False, description="Whether this agent uses Temporal workflows" - ) - workflow: TemporalWorkflowConfig | None = Field( - default=None, - description="Temporal workflow configuration. Required when enabled=True. (deprecated: use workflows instead)", - ) - workflows: list[TemporalWorkflowConfig] | None = Field( - default=None, - description="List of temporal workflow configurations. Used when enabled=true.", - ) - health_check_port: int | None = Field( - default=None, - description="Port for temporal worker health check endpoint. Defaults to 80 if not specified.", - ) - - @field_validator("workflows") - @classmethod - def validate_workflows_not_empty(cls, v): - """Ensure workflows list is not empty when provided""" - if v is not None and len(v) == 0: - raise ValueError("workflows list cannot be empty when provided") - return v - - @model_validator(mode="after") - def validate_temporal_config_when_enabled(self): - """Validate that workflow configuration exists when enabled=true""" - if self.enabled: - # Must have either workflow (legacy) or workflows (new) - if not self.workflow and (not self.workflows or len(self.workflows) == 0): - raise ValueError( - "When temporal.enabled=true, either 'workflow' or 'workflows' must be provided and non-empty" - ) - - return self diff --git a/src/agentex/config/agent_manifest.py b/src/agentex/config/agent_manifest.py deleted file mode 100644 index 6d727479b..000000000 --- a/src/agentex/config/agent_manifest.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -from pydantic import Field - -from agentex.config._base import ConfigBaseModel -from agentex.config.agent_config import AgentConfig -from agentex.config.build_config import BuildConfig -from agentex.config.deployment_config import DeploymentConfig -from agentex.config.local_development_config import LocalDevelopmentConfig - - -class AgentManifest(ConfigBaseModel): - """ - Represents a manifest file that describes how to build and deploy an agent. - """ - - build: BuildConfig - agent: AgentConfig - local_development: LocalDevelopmentConfig | None = Field( - default=None, description="Configuration for local development" - ) - deployment: DeploymentConfig | None = Field( - default=None, description="Deployment configuration for the agent" - ) diff --git a/src/agentex/config/build_config.py b/src/agentex/config/build_config.py deleted file mode 100644 index bf4a0b309..000000000 --- a/src/agentex/config/build_config.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -from pydantic import Field - -from agentex.config._base import ConfigBaseModel - - -class BuildContext(ConfigBaseModel): - """ - Represents the context in which the Docker image should be built. - """ - - root: str = Field( - ..., - description="The root directory of the build context. Should be specified relative to the location of the " - "build config file.", - ) - include_paths: list[str] = Field( - default_factory=list, - description="The paths to include in the build context. Should be specified relative to the root directory.", - ) - dockerfile: str = Field( - ..., - description="The path to the Dockerfile. Should be specified relative to the root directory.", - ) - dockerignore: str | None = Field( - None, - description="The path to the .dockerignore file. Should be specified relative to the root directory.", - ) - - -class BuildConfig(ConfigBaseModel): - """ - Represents a configuration for building the action as a Docker image. - """ - - context: BuildContext diff --git a/src/agentex/config/credentials.py b/src/agentex/config/credentials.py deleted file mode 100644 index 7f4b79d1c..000000000 --- a/src/agentex/config/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/config/deployment_config.py b/src/agentex/config/deployment_config.py deleted file mode 100644 index c42a8db97..000000000 --- a/src/agentex/config/deployment_config.py +++ /dev/null @@ -1,117 +0,0 @@ -from __future__ import annotations - -from typing import Any, Dict - -from pydantic import Field - -from agentex.config._base import ConfigBaseModel - - -class ImageConfig(ConfigBaseModel): - """Configuration for container images""" - - repository: str = Field(..., description="Container image repository URL") - tag: str = Field(default="latest", description="Container image tag") - - -class ImagePullSecretConfig(ConfigBaseModel): - """Configuration for image pull secrets""" - - name: str = Field(..., description="Name of the image pull secret") - - -class ResourceRequirements(ConfigBaseModel): - """Resource requirements for containers""" - - cpu: str = Field( - default="500m", description="CPU request/limit (e.g., '500m', '1')" - ) - memory: str = Field( - default="1Gi", description="Memory request/limit (e.g., '1Gi', '512Mi')" - ) - - -class ResourceConfig(ConfigBaseModel): - """Resource configuration for containers""" - - requests: ResourceRequirements = Field( - default_factory=ResourceRequirements, description="Resource requests" - ) - limits: ResourceRequirements = Field( - default_factory=ResourceRequirements, description="Resource limits" - ) - - -class GlobalDeploymentConfig(ConfigBaseModel): - """Global deployment configuration that applies to all clusters""" - - agent: dict[str, str] = Field( - default_factory=dict, description="Agent metadata (name, description)" - ) - replicaCount: int = Field(default=1, description="Number of replicas to deploy") - resources: ResourceConfig = Field( - default_factory=ResourceConfig, description="Resource requirements" - ) - - -class DeploymentConfig(ConfigBaseModel): - """Main deployment configuration in the manifest""" - - image: ImageConfig = Field(..., description="Container image configuration") - imagePullSecrets: list[ImagePullSecretConfig] | None = Field( - default=None, description="Image pull secrets to use for the deployment" - ) - global_config: GlobalDeploymentConfig = Field( - default_factory=GlobalDeploymentConfig, - description="Global deployment settings", - alias="global", - ) - - -class ClusterConfig(ConfigBaseModel): - """Per-cluster deployment overrides""" - - image: ImageConfig | None = Field( - default=None, description="Cluster-specific image overrides" - ) - replicaCount: int | None = Field( - default=None, description="Cluster-specific replica count" - ) - resources: ResourceConfig | None = Field( - default=None, description="Cluster-specific resource overrides" - ) - env: list[dict[str, str]] | None = Field( - default=None, description="Additional environment variables for this cluster" - ) - # Allow additional arbitrary overrides for advanced users - additional_overrides: dict[str, Any] | None = Field( - default=None, description="Additional helm chart value overrides" - ) - - -class AuthenticationConfig(ConfigBaseModel): - principal: Dict[str, Any] = Field(description="Principal used for authorization on registration") - - -class InjectedImagePullSecretValues(ConfigBaseModel): - """Values for image pull secrets""" - - registry: str = Field(..., description="Registry of the image pull secret") - username: str = Field(..., description="Username of the image pull secret") - password: str = Field(..., description="Password of the image pull secret") - email: str | None = Field( - default=None, description="Email of the image pull secret" - ) - - -class InjectedSecretsValues(ConfigBaseModel): - """Values for injected secrets""" - - # Defined as a dictionary because the names need to be unique - credentials: dict[str, Any] = Field( - default_factory=dict, description="Secrets to inject into the deployment" - ) - imagePullSecrets: dict[str, InjectedImagePullSecretValues] = Field( - default_factory=dict, - description="Image pull secrets to inject into the deployment", - ) diff --git a/src/agentex/config/environment_config.py b/src/agentex/config/environment_config.py deleted file mode 100644 index e3928d283..000000000 --- a/src/agentex/config/environment_config.py +++ /dev/null @@ -1,217 +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. The -yaml-loading helpers live in `agentex.lib.sdk.config.environment_config` -so these models stay slim-safe. -""" - -from __future__ import annotations - -from typing import Any, Dict, Literal - -from pydantic import Field, BaseModel, field_validator - -from agentex.config._base import ConfigBaseModel - - -class AgentAuthConfig(BaseModel): - """Authentication configuration for an agent in a specific environment.""" - - principal: Dict[str, Any] = Field( - ..., description="Principal configuration for agent authorization and registration" - ) - - @field_validator("principal") - @classmethod - def validate_principal_required_fields(cls, v: Any) -> Dict[str, Any]: - """Ensure principal has required fields for agent registration.""" - if not isinstance(v, dict): - raise ValueError("Principal must be a dictionary") - return v - - -class AgentKubernetesConfig(BaseModel): - """Kubernetes configuration for an agent in a specific environment.""" - - namespace: str = Field(..., description="Kubernetes namespace where the agent will be deployed") - - @field_validator("namespace") - @classmethod - def validate_namespace_format(cls, v: str) -> str: - """Ensure namespace follows Kubernetes naming conventions.""" - if not v or not v.strip(): - raise ValueError("Namespace cannot be empty") - - # Basic Kubernetes namespace validation - namespace = v.strip().lower() - if not namespace.replace("-", "").replace(".", "").isalnum(): - raise ValueError(f"Namespace '{v}' must contain only lowercase letters, numbers, hyphens, and periods") - - if len(namespace) > 63: - raise ValueError(f"Namespace '{v}' cannot exceed 63 characters") - - return namespace - - -class OciRegistryConfig(BaseModel): - """OCI registry configuration for Helm chart deployments.""" - - url: str = Field( - ..., - description="OCI registry URL for Helm charts (e.g., 'us-west1-docker.pkg.dev/project/repo'). " - "When set, OCI mode is used instead of classic helm repo.", - ) - provider: Literal["gar"] | None = Field( - default=None, - description="OCI registry provider for provider-specific features. " - "Set to 'gar' for Google Artifact Registry to enable auto-authentication via gcloud " - "and latest version fetching. When not set, assumes user has already authenticated.", - ) - chart_version: str | None = Field( - default=None, description="Helm chart version to deploy. If not set, uses the default version from the CLI." - ) - - -class AgentEnvironmentConfig(BaseModel): - """Complete configuration for an agent in a specific environment.""" - - kubernetes: AgentKubernetesConfig | None = Field(default=None, description="Kubernetes deployment configuration") - environment: str | None = Field( - default=None, - description="The environment keyword that this specific environment maps to: either dev, staging, prod", - ) - auth: AgentAuthConfig = Field(..., description="Authentication and authorization configuration") - helm_repository_name: str = Field(default="scale-egp", description="Helm repository name for the environment") - helm_repository_url: str = Field( - default="https://scale-egp-helm-charts-us-west-2.s3.amazonaws.com/charts", - description="Helm repository url for the environment (classic mode)", - ) - oci_registry: OciRegistryConfig | None = Field( - default=None, description="OCI registry configuration. When set, OCI mode is used instead of classic helm repo." - ) - helm_overrides: Dict[str, Any] = Field( - default_factory=dict, description="Helm chart value overrides for environment-specific tuning" - ) - - -class AgentEnvironmentsConfig(ConfigBaseModel): - """All environment configurations for an agent.""" - - schema_version: str = Field(default="v1", description="Schema version for validation and compatibility") - environments: Dict[str, AgentEnvironmentConfig] = Field( - ..., description="Environment-specific configurations (dev, prod, etc.)" - ) - - @field_validator("schema_version") - @classmethod - def validate_schema_version(cls, v: str) -> str: - """Ensure schema version is supported.""" - supported_versions = ["v1"] - if v not in supported_versions: - raise ValueError(f"Schema version '{v}' not supported. Supported versions: {', '.join(supported_versions)}") - return v - - @field_validator("environments") - @classmethod - def validate_environments_not_empty(cls, v: Dict[str, AgentEnvironmentConfig]) -> Dict[str, AgentEnvironmentConfig]: - """Ensure at least one environment is defined.""" - if not v: - raise ValueError("At least one environment must be defined") - return v - - def get_config_for_env(self, env_name: str) -> AgentEnvironmentConfig: - """Get configuration for a specific environment. - - Args: - env_name: Name of the environment (e.g., 'dev', 'prod') - - Returns: - AgentEnvironmentConfig for the specified environment - - Raises: - ValueError: If environment is not found - """ - if env_name not in self.environments: - available_envs = ", ".join(self.environments.keys()) - raise ValueError( - f"Environment '{env_name}' not found in environments.yaml. Available environments: {available_envs}" - ) - return self.environments[env_name] - - def get_configs_for_env(self, env_target: str) -> dict[str, AgentEnvironmentConfig]: - """Get configuration for a specific environment based on the expected mapping. - The environment is either: - 1. explicitly specified like so using a key-map in the environments conifg: - environments: - dev-aws: - environment: "dev" - kubernetes: - namespace: "sgp-000-hello-acp" - auth: - principal: - user_id: 73d0c8bd-4726-434c-9686-eb627d89f078 - account_id: 6887f093600ecd59bbbd3095 - helm_overrides: - - or: it it can be defined at the top level: - dev: - kubernetes: - namespace: "sgp-000-hello-acp" - auth: - principal: - user_id: 73d0c8bd-4726-434c-9686-eb627d89f078 - account_id: 6887f093600ecd59bbbd3095 - helm_overrides: - - The principal must contain exactly one of `user_id` or `service_account_id`. - Use `service_account_id` to register an agent under a service account - instead of a personal user identity: - dev: - kubernetes: - namespace: "sgp-000-hello-acp" - auth: - principal: - service_account_id: a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d - account_id: 6887f093600ecd59bbbd3095 - - if the environment field is not explicitly set, we assume its the same as - the name of the environment - Args: - env_target: Name of the environment target (e.g., 'dev', 'prod') - - Returns: - AgentEnvironmentConfig for the specified environment - - Raises: - ValueError: If environment is not found - """ - envs_to_deploy = {} - if env_target in self.environments: - # this supports if the top-level key is just "dev, staging, etc" and matches - # the environment name exactly without any explicit mapping - envs_to_deploy[env_target] = self.environments[env_target] - - for env_name, config in self.environments.items(): - if config.environment == env_target: - envs_to_deploy[env_name] = config - - if len(envs_to_deploy) == 0: - ## this just finds environments for each target, so "available_envs" refers to each target environment - - available_envs = set() - for env_name, config in self.environments.items(): - if config.environment is not None: - available_envs.add(config.environment) - else: - available_envs.add(env_name) - raise ValueError( - f"Environment '{env_target}' not found in environments.yaml. Available environments: {available_envs}" - ) - - return envs_to_deploy - - def list_environments(self) -> list[str]: - """Get list of all configured environment names.""" - return list(self.environments.keys()) diff --git a/src/agentex/config/local_development_config.py b/src/agentex/config/local_development_config.py deleted file mode 100644 index 30b3f59fe..000000000 --- a/src/agentex/config/local_development_config.py +++ /dev/null @@ -1,59 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from pydantic import Field, field_validator - -from agentex.config._base import ConfigBaseModel - - -class LocalAgentConfig(ConfigBaseModel): - """Configuration for local agent development""" - - port: int = Field( - ..., - description="The port where the agent's ACP server is running locally", - gt=0, - lt=65536, - ) - host_address: str = Field( - default="host.docker.internal", - description="The host address where the agent's ACP server can be reached (e.g., host.docker.internal for Docker, localhost for direct)", - ) - - -class LocalPathsConfig(ConfigBaseModel): - """Configuration for local file paths""" - - acp: str = Field( - default="project/acp.py", - description="Path to the ACP server file. Can be relative to manifest directory or absolute.", - ) - worker: str | None = Field( - default=None, - description="Path to the temporal worker file. Can be relative to manifest directory or absolute. (only for temporal agents)", - ) - - @field_validator("acp", "worker") - @classmethod - def validate_path_format(cls, v): - """Validate that the path is a reasonable format""" - if v is None: - return v - - # Convert to Path to validate format - try: - Path(v) - except Exception as e: - raise ValueError(f"Invalid path format: {v}") from e - - return v - - -class LocalDevelopmentConfig(ConfigBaseModel): - """Configuration for local development environment""" - - agent: LocalAgentConfig = Field(..., description="Local agent configuration") - paths: LocalPathsConfig | None = Field( - default=None, description="File paths for local development" - ) diff --git a/src/agentex/lib/__init__.py b/src/agentex/lib/__init__.py deleted file mode 100644 index 9d960dfee..000000000 --- a/src/agentex/lib/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from agentex.lib._version_guard import verify_client_compatibility - -# Fail fast + clearly on a skewed/incomplete agentex-client install. -verify_client_compatibility() diff --git a/src/agentex/lib/_version_guard.py b/src/agentex/lib/_version_guard.py deleted file mode 100644 index a05572efc..000000000 --- a/src/agentex/lib/_version_guard.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Fail fast with a clear error on an incomplete agentex-client install instead -of a cryptic `cannot import name ... from agentex.types`.""" - -from __future__ import annotations - -from importlib.metadata import PackageNotFoundError, version - - -def _installed(package: str) -> str: - try: - return version(package) - except PackageNotFoundError: - return "unknown" - - -def verify_client_compatibility() -> None: - # Canary on the client REST surface, not the version: newer clients are fine - # (additive); we only fail if a symbol/resource the ADK needs is absent. - try: - from agentex.types import Event as _Event # noqa: F401 - from agentex.resources import states as _states # noqa: F401 - except (ImportError, AttributeError) as exc: - raise ImportError( - f"agentex-sdk could not import the agentex-client REST surface it " - f"depends on (agentex-sdk={_installed('agentex-sdk')}, " - f"agentex-client={_installed('agentex-client')}). Reinstall both at a " - f"compatible version, e.g. `pip install --force-reinstall agentex-sdk`." - ) from exc diff --git a/src/agentex/lib/adk/__init__.py b/src/agentex/lib/adk/__init__.py deleted file mode 100644 index 25b858485..000000000 --- a/src/agentex/lib/adk/__init__.py +++ /dev/null @@ -1,106 +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_turn import LangGraphTurn, stream_langgraph_events -from agentex.lib.adk._modules._langgraph_sync import ( - emit_langgraph_messages, - convert_langgraph_to_agentex_events, -) -from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn, stream_pydantic_ai_events -from agentex.lib.adk._modules._pydantic_ai_sync import convert_pydantic_ai_to_agentex_events -from agentex.lib.adk._modules._openai_sync import convert_openai_to_agentex_events -from agentex.lib.adk._modules._openai_turn import OpenAITurn, openai_usage_to_turn_usage -from agentex.lib.adk._modules._claude_code_sync import convert_claude_code_to_agentex_events -from agentex.lib.adk._modules._claude_code_turn import ( - ClaudeCodeTurn, - claude_code_usage_to_turn_usage, -) -from agentex.lib.adk._modules._codex_sync import convert_codex_to_agentex_events -from agentex.lib.adk._modules._codex_turn import CodexTurn, codex_usage_to_turn_usage -from agentex.lib.adk._modules.events import EventsModule -from agentex.lib.adk._modules.messages import MessagesModule -from agentex.lib.adk._modules.state import StateModule -from agentex.lib.adk._modules.streaming import StreamingModule -from agentex.lib.adk._modules.tasks import TasksModule -from agentex.lib.adk._modules.tracing import TracingModule, TurnSpan - -# Unified harness surface (AGX1-375) -from agentex.lib.core.harness import ( - UnifiedEmitter, - SpanTracer, - OpenSpan, - CloseSpan, - SpanSignal, - StreamTaskMessage, - TurnUsage, - TurnResult, - HarnessTurn, -) - -from agentex.lib.adk import providers -from agentex.lib.adk import utils - -acp = ACPModule() -agents = AgentsModule() -tasks = TasksModule() -messages = MessagesModule() -state = StateModule() -streaming = StreamingModule() -tracing = TracingModule() -events = EventsModule() -agent_task_tracker = AgentTaskTrackerModule() - -__all__ = [ - # Core - "acp", - "agents", - "tasks", - "messages", - "state", - "streaming", - "tracing", - "events", - "agent_task_tracker", - "TurnSpan", - # Checkpointing / LangGraph - "create_checkpointer", - "stream_langgraph_events", - "emit_langgraph_messages", - "convert_langgraph_to_agentex_events", - "LangGraphTurn", - # Pydantic AI - "stream_pydantic_ai_events", - "convert_pydantic_ai_to_agentex_events", - "PydanticAITurn", - # OpenAI Agents - "convert_openai_to_agentex_events", - "OpenAITurn", - "openai_usage_to_turn_usage", - # Claude Code - "convert_claude_code_to_agentex_events", - "ClaudeCodeTurn", - "claude_code_usage_to_turn_usage", - # Codex - "convert_codex_to_agentex_events", - "CodexTurn", - "codex_usage_to_turn_usage", - # Unified harness surface (AGX1-375) - "UnifiedEmitter", - "SpanTracer", - "OpenSpan", - "CloseSpan", - "SpanSignal", - "StreamTaskMessage", - "TurnUsage", - "TurnResult", - "HarnessTurn", - # Providers - "providers", - # Utils - "utils", -] diff --git a/src/agentex/lib/adk/_modules/__init__.py b/src/agentex/lib/adk/_modules/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/agentex/lib/adk/_modules/_claude_code_sync.py b/src/agentex/lib/adk/_modules/_claude_code_sync.py deleted file mode 100644 index e8daa44a3..000000000 --- a/src/agentex/lib/adk/_modules/_claude_code_sync.py +++ /dev/null @@ -1,417 +0,0 @@ -"""Claude Code stream-json parser tap for the unified harness surface. - -Converts the newline-delimited JSON envelopes emitted by -``claude -p --output-format stream-json`` into the canonical -``StreamTaskMessage*`` stream consumed by the Agentex harness. - -Envelope โ†’ canonical mapping ------------------------------ -system/init - Ignored at this layer (session_id tracking is a provider concern). - -assistant / user (content blocks) - text block โ†’ Start(TextContent) + Delta(TextDelta)* + Done - thinking block โ†’ Start(ReasoningContent) + Delta(ReasoningContentDelta)* + Done - tool_use block โ†’ Start(ToolRequestContent) + Done (Full args in Start content) - tool_result block โ†’ Full(ToolResponseContent) - -stream_event / content_block_start - type=text โ†’ Start(TextContent, empty) - type=thinking โ†’ Start(ReasoningContent, empty) - -stream_event / content_block_delta - type=text_delta โ†’ Delta(TextDelta) - type=thinking_delta โ†’ Delta(ReasoningContentDelta) - -stream_event / content_block_stop - (text open) โ†’ Done - (thinking open) โ†’ Done (full text known here; update Full via Full event first) - -result - Fires ``on_result`` with the raw envelope so the caller can capture - usage and cost. No StreamTaskMessage is emitted for the result itself. - -Out of scope ------------- -No deployable test agent is provided. claude-code requires the golden -agent's sandbox/subprocess/secret/MCP orchestration to produce the stream. -Live coverage is the golden agent, which will adopt this tap. Do NOT add an -examples/ agent or CI live-matrix row for claude-code. -""" - -from __future__ import annotations - -import json -from typing import Any, Callable, Awaitable, AsyncIterator - -from agentex.lib.utils.logging import make_logger -from agentex.types.text_content import TextContent -from agentex.types.reasoning_content import ReasoningContent -from agentex.types.task_message_delta import TextDelta -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.types.reasoning_content_delta import ReasoningContentDelta - -logger = make_logger(__name__) - -_MAX_RESULT_LENGTH = 4000 - - -def _truncate(text: str) -> str: - return str(text)[:_MAX_RESULT_LENGTH] - - -def _extract_summary(text: str, max_len: int = 300) -> str: - return text.strip().split("\n", 1)[0][:max_len] - - -async def convert_claude_code_to_agentex_events( - lines: AsyncIterator[str | dict[str, Any]], - on_result: Callable[[dict[str, Any]], Awaitable[None]] | None = None, - on_init: Callable[[dict[str, Any]], Awaitable[None]] | None = None, -) -> AsyncIterator[StreamTaskMessageStart | StreamTaskMessageDelta | StreamTaskMessageFull | StreamTaskMessageDone]: - """Public tap: convert a claude-code ``stream-json`` line stream to events. - - Thin wrapper over :func:`_convert_claude_code_impl` that owns the - cancellation backstop: a ``finally`` closes the underlying ``lines`` iterator - (when it exposes ``aclose``) whenever this generator is closed โ€” including on - the ``GeneratorExit``/``CancelledError`` raised when a consuming task is - cancelled mid-turn by an interrupt. This terminates the CLI stdout handle / - subprocess instead of leaking it. Kept as a wrapper (rather than a - ``try/finally`` inside the impl) so the large parser body stays untouched. - """ - inner = _convert_claude_code_impl(lines, on_result=on_result, on_init=on_init) - try: - async for event in inner: - yield event - finally: - inner_aclose = getattr(inner, "aclose", None) - if inner_aclose is not None: - await inner_aclose() - aclose = getattr(lines, "aclose", None) - if aclose is not None: - await aclose() - - -async def _convert_claude_code_impl( - lines: AsyncIterator[str | dict[str, Any]], - on_result: Callable[[dict[str, Any]], Awaitable[None]] | None = None, - on_init: Callable[[dict[str, Any]], Awaitable[None]] | None = None, -) -> AsyncIterator[StreamTaskMessageStart | StreamTaskMessageDelta | StreamTaskMessageFull | StreamTaskMessageDone]: - """Convert a claude-code ``stream-json`` line stream into Agentex ``StreamTaskMessage*`` events. - - Each item in ``lines`` is either a raw JSON string (as read from the CLI's - stdout) or an already-parsed dict. Empty strings are skipped; unparseable - JSON is logged and skipped. - - ``on_result`` is called with the ``result`` envelope when it arrives so the - caller can capture usage and cost. It is awaited before the generator - continues. When ``None``, the result envelope is silently dropped. - - ``on_init`` is called with the ``system``/``init`` envelope the moment it - arrives (the FIRST envelope of a claude-code stream), so the caller can - capture ``session_id`` EARLY โ€” before the turn completes. This is what makes - an interrupted-before-completion turn resumable: the terminal ``result`` - envelope (which ``on_result`` reads) never arrives when a turn is cut short, - so relying on it alone loses the session id. Mirrors how the inline - ``claude_agents`` activity captures session_id from its SystemMessage init. - When ``None``, the init envelope's session metadata is not surfaced early. - - Envelope โ†’ canonical mapping is documented in this module's docstring. - - A ``finally`` closes the underlying ``lines`` iterator (when it exposes - ``aclose``) as a backstop, so a cancellation mid-turn (e.g. an interrupt that - cancels the consuming task) does not leak the CLI stdout handle / subprocess. - """ - next_index = 0 - tool_call_count = 0 - - # Streaming state for content_block_start / content_block_delta / - # content_block_stop triples. - _thinking_open = False - _thinking_buf = "" - _thinking_index: int | None = None - _text_open = False - _text_buf = "" - _text_index: int | None = None - # Full text of each block already delivered via stream_event deltas, so the - # materialised assistant envelope does not re-emit it. Matched by CONTENT, - # not block index: a single streamed message can arrive as several assistant - # envelopes (e.g. a thinking block, then the text block), and the per-block - # numeric index does not survive that split while the text does. Each match - # is consumed (one entry removed) so a genuinely repeated later block โ€” a new - # turn that happens to emit identical text โ€” is still delivered. - _streamed_texts: list[str] = [] - _streamed_thinkings: list[str] = [] - - async for raw in lines: - if not raw: - continue - - if isinstance(raw, dict): - evt = raw - else: - line = raw.strip() - if not line: - continue - try: - evt = json.loads(line) - except json.JSONDecodeError: - logger.debug("claude-code: skipping non-JSON line: %r", line[:120]) - continue - - evt_type = evt.get("type", "") - - # ----------------------------------------------------------------------- - # assistant / user โ€” materialised content blocks - # ----------------------------------------------------------------------- - if evt_type in ("assistant", "user"): - msg = evt.get("message", {}) - blocks = msg.get("content", []) - if not isinstance(blocks, list): - blocks = [blocks] - - for block in blocks: - if not isinstance(block, dict): - continue - block_type = block.get("type", "") - - if block_type == "text": - text = block.get("text", "") - if not text: - continue - # Skip blocks already delivered via stream_event deltas. Two - # cases: (1) the streamed block already finished โ€” its full - # text is recorded in _streamed_texts; (2) the materialised - # envelope arrives INTERLEAVED, mid-stream, before the streamed - # block's content_block_stop records its buffer โ€” the still-open - # block's partial buffer is a prefix of this full text. - if text in _streamed_texts: - _streamed_texts.remove(text) - continue - if _text_open and _text_buf and text.startswith(_text_buf): - continue - msg_index = next_index - next_index += 1 - yield StreamTaskMessageStart( - type="start", - index=msg_index, - content=TextContent( - type="text", - author="agent", - content="", - ), - ) - yield StreamTaskMessageDelta( - type="delta", - index=msg_index, - delta=TextDelta(type="text", text_delta=text), - ) - yield StreamTaskMessageDone(type="done", index=msg_index) - - elif block_type == "thinking": - thinking_text = block.get("thinking", "") - if thinking_text: - # Skip blocks already delivered via stream_event deltas. - # Same two cases as text above: finished streamed block - # (recorded), or an interleaved materialised envelope whose - # text the still-open streamed buffer is a prefix of. - if thinking_text in _streamed_thinkings: - _streamed_thinkings.remove(thinking_text) - continue - if _thinking_open and _thinking_buf and thinking_text.startswith(_thinking_buf): - continue - summary = _extract_summary(thinking_text) - msg_index = next_index - next_index += 1 - yield StreamTaskMessageStart( - type="start", - index=msg_index, - content=ReasoningContent( - type="reasoning", - author="agent", - summary=[summary], - content=[], - style="active", - ), - ) - yield StreamTaskMessageDelta( - type="delta", - index=msg_index, - delta=ReasoningContentDelta( - type="reasoning_content", - content_index=0, - content_delta=thinking_text, - ), - ) - yield StreamTaskMessageDone(type="done", index=msg_index) - - elif block_type == "tool_use": - tool_call_count += 1 - tool_id = block.get("id", f"tool_{tool_call_count}") - name = block.get("name", "unknown") - arguments = block.get("input", {}) - if not isinstance(arguments, dict): - arguments = {} - msg_index = next_index - next_index += 1 - yield StreamTaskMessageStart( - type="start", - index=msg_index, - content=ToolRequestContent( - type="tool_request", - author="agent", - tool_call_id=tool_id, - name=name, - arguments=arguments, - ), - ) - yield StreamTaskMessageDone(type="done", index=msg_index) - - elif block_type == "tool_result": - tool_id = block.get("tool_use_id", "") - content = block.get("content", "") - is_error = block.get("is_error", False) - if isinstance(content, list): - content = "\n".join(b.get("text", str(b)) if isinstance(b, dict) else str(b) for b in content) - result_str = _truncate(str(content)) - msg_index = next_index - next_index += 1 - yield StreamTaskMessageFull( - type="full", - index=msg_index, - content=ToolResponseContent( - type="tool_response", - author="agent", - tool_call_id=tool_id, - name="", - content={"result": result_str, **({"is_error": True} if is_error else {})}, - ), - ) - - # ----------------------------------------------------------------------- - # stream_event โ€” incremental streaming deltas - # ----------------------------------------------------------------------- - elif evt_type == "stream_event": - se = evt.get("event") or {} - se_type = se.get("type", "") - - if se_type == "content_block_start": - block = se.get("content_block") or {} - btype = block.get("type") - - if btype == "thinking": - _thinking_open = True - _thinking_buf = "" - msg_index = next_index - next_index += 1 - _thinking_index = msg_index - yield StreamTaskMessageStart( - type="start", - index=msg_index, - content=ReasoningContent( - type="reasoning", - author="agent", - summary=[], - content=[], - style="active", - ), - ) - - elif btype == "text": - _text_open = True - _text_buf = "" - msg_index = next_index - next_index += 1 - _text_index = msg_index - yield StreamTaskMessageStart( - type="start", - index=msg_index, - content=TextContent( - type="text", - author="agent", - content="", - ), - ) - - elif se_type == "content_block_delta": - delta = se.get("delta") or {} - dtype = delta.get("type") - - if dtype == "thinking_delta": - chunk = delta.get("thinking", "") - if chunk and _thinking_open: - _thinking_buf += chunk - if _thinking_index is not None: - yield StreamTaskMessageDelta( - type="delta", - index=_thinking_index, - delta=ReasoningContentDelta( - type="reasoning_content", - content_index=0, - content_delta=chunk, - ), - ) - - elif dtype == "text_delta": - chunk = delta.get("text", "") - if chunk and _text_open: - _text_buf += chunk - if _text_index is not None: - yield StreamTaskMessageDelta( - type="delta", - index=_text_index, - delta=TextDelta(type="text", text_delta=chunk), - ) - - elif se_type == "content_block_stop": - if _thinking_open: - _thinking_open = False - # Record the streamed thinking so the materialised assistant - # envelope doesn't re-emit it. Skip empties: a block_start with - # no deltas leaves the assistant envelope free to fill the text. - if _thinking_buf: - _streamed_thinkings.append(_thinking_buf) - _thinking_buf = "" - if _thinking_index is not None: - yield StreamTaskMessageDone(type="done", index=_thinking_index) - _thinking_index = None - elif _text_open: - _text_open = False - # Record the streamed text for content-based dedup against the - # materialised assistant envelope (see _streamed_texts). - if _text_buf: - _streamed_texts.append(_text_buf) - _text_buf = "" - if _text_index is not None: - yield StreamTaskMessageDone(type="done", index=_text_index) - _text_index = None - - # ----------------------------------------------------------------------- - # system / init โ€” session metadata (ignored at this layer) - # ----------------------------------------------------------------------- - elif evt_type == "system": - # Session ID tracking and MCP status logging are provider concerns: - # this pure parser layer emits no StreamTaskMessage for system events. - # It DOES surface the init envelope's session metadata early via - # on_init so a caller can capture session_id before the turn's - # terminal ``result`` arrives โ€” required for resuming a turn that was - # interrupted before completion (no ``result`` is ever emitted then). - if on_init is not None and evt.get("subtype") == "init": - await on_init(evt) - - # ----------------------------------------------------------------------- - # result โ€” carries usage + cost; fired to on_result, not emitted as msgs - # ----------------------------------------------------------------------- - elif evt_type == "result": - if on_result is not None: - await on_result(evt) - - else: - logger.debug("claude-code: unhandled envelope type %r", evt_type) diff --git a/src/agentex/lib/adk/_modules/_claude_code_turn.py b/src/agentex/lib/adk/_modules/_claude_code_turn.py deleted file mode 100644 index d41afafe7..000000000 --- a/src/agentex/lib/adk/_modules/_claude_code_turn.py +++ /dev/null @@ -1,175 +0,0 @@ -"""ClaudeCodeTurn โ€” HarnessTurn implementation for the claude-code tap. - -Wraps ``convert_claude_code_to_agentex_events`` to implement the -``HarnessTurn`` protocol: exposes ``events`` (the canonical -``StreamTaskMessage*`` stream) and ``usage()`` (the normalised -``TurnUsage``, populated after the stream is exhausted). - -Usage normalization -------------------- -Claude Code's ``result`` envelope carries usage under several key shapes -depending on the CLI version. We defensive-map all known shapes: - - result.usage.input_tokens -> input_tokens - result.usage.output_tokens -> output_tokens - result.usage.cache_read_input_tokens - result.usage.cache_creation_input_tokens -> cached_input_tokens (sum) - result.cost_usd / result.total_cost_usd -> cost_usd - result.duration_ms -> duration_ms - result.num_turns -> num_llm_calls - -Real zeros are preserved; missing keys default to ``None`` (not zero) so -downstream consumers can distinguish "not reported" from "zero". - -Out of scope: no deployable test agent is provided โ€” see module docstring -in ``_claude_code_sync.py``. -""" - -from __future__ import annotations - -from typing import Any, AsyncIterator - -from agentex.lib.core.harness.types import TurnUsage, HarnessTurn, StreamTaskMessage -from agentex.lib.adk._modules._claude_code_sync import convert_claude_code_to_agentex_events - - -def claude_code_usage_to_turn_usage(result_envelope: dict[str, Any]) -> TurnUsage: - """Map a claude-code ``result`` envelope to a canonical ``TurnUsage``. - - Defensively handles missing / None values. Real zeros are preserved. - ``cost_usd`` checks both ``cost_usd`` and ``total_cost_usd`` keys (the - CLI has used both across versions). - ``cached_input_tokens`` accumulates cache_read and cache_creation counts - since both represent tokens served from the prompt cache. - """ - usage_raw: dict[str, Any] = result_envelope.get("usage") or {} - - def _int(d: dict[str, Any], key: str) -> int | None: - v = d.get(key) - if v is None: - return None - try: - return int(v) - except (TypeError, ValueError): - return None - - def _float(d: dict[str, Any], *keys: str) -> float | None: - for key in keys: - v = d.get(key) - if v is not None: - try: - return float(v) - except (TypeError, ValueError): - continue - return None - - input_tokens = _int(usage_raw, "input_tokens") - output_tokens = _int(usage_raw, "output_tokens") - - # Aggregate both cache_read and cache_creation into cached_input_tokens - cache_read = _int(usage_raw, "cache_read_input_tokens") - cache_creation = _int(usage_raw, "cache_creation_input_tokens") - if cache_read is not None or cache_creation is not None: - cached_input_tokens = (cache_read or 0) + (cache_creation or 0) - else: - cached_input_tokens = None - - total_tokens: int | None = None - if input_tokens is not None and output_tokens is not None: - total_tokens = input_tokens + output_tokens - - cost_usd = _float(result_envelope, "cost_usd", "total_cost_usd") - duration_ms = _int(result_envelope, "duration_ms") - - # num_llm_calls is provider-reported (from num_turns): default None ("not - # reported") rather than 0 so callers can distinguish it from a real zero, - # matching the None convention used for the token fields above. - num_turns = result_envelope.get("num_turns") - num_llm_calls: int | None = None - if num_turns is not None: - try: - num_llm_calls = int(num_turns) - except (TypeError, ValueError): - pass - - return TurnUsage( - input_tokens=input_tokens, - output_tokens=output_tokens, - cached_input_tokens=cached_input_tokens, - total_tokens=total_tokens, - cost_usd=cost_usd, - duration_ms=duration_ms, - num_llm_calls=num_llm_calls, - ) - - -class ClaudeCodeTurn: - """HarnessTurn for a claude-code ``stream-json`` line stream. - - Satisfies the ``HarnessTurn`` protocol: - - ``events`` yields the canonical ``StreamTaskMessage*`` stream. - - ``usage()`` returns the normalised ``TurnUsage`` (only valid after - ``events`` is fully consumed). - - ``lines`` is an async iterator of raw JSON strings or pre-parsed dicts, as - produced by reading the claude-code CLI's stdout line by line. - """ - - def __init__(self, lines: AsyncIterator[str | dict[str, Any]]) -> None: - self._lines = lines - self._result_envelope: dict[str, Any] | None = None - # session_id captured from the early system/init envelope. This is what - # keeps an interrupted-before-completion turn resumable: the terminal - # ``result`` envelope never arrives when a turn is cut short, so we must - # capture the session id up front (from init) rather than only at the end. - self._init_session_id: str | None = None - self._events_stream: AsyncIterator[StreamTaskMessage] | None = None - - async def _on_result(self, envelope: dict[str, Any]) -> None: - self._result_envelope = envelope - - async def _on_init(self, envelope: dict[str, Any]) -> None: - sid = envelope.get("session_id") - if sid: - self._init_session_id = sid - - @property - def events(self) -> AsyncIterator[StreamTaskMessage]: - if self._events_stream is None: - self._events_stream = convert_claude_code_to_agentex_events( - self._lines, - on_result=self._on_result, - on_init=self._on_init, - ) - return self._events_stream - - @property - def session_id(self) -> str | None: - """The Claude Code session id, for resuming a multi-turn session. - - Prefers the id from the terminal ``result`` envelope (fully-complete - turn), then falls back to the id captured from the early ``system/init`` - envelope. The init fallback is what makes a turn that was interrupted - before completion still resumable (no ``result`` is emitted then). - Returns ``None`` only if neither envelope was seen (e.g. the stream was - truncated before init) or Claude Code reported no session id. - """ - if self._result_envelope and self._result_envelope.get("session_id"): - return self._result_envelope.get("session_id") - return self._init_session_id - - def usage(self) -> TurnUsage: - """Return normalised usage for this turn. - - Call only after ``events`` is exhausted. Returns an empty ``TurnUsage`` - if the ``result`` envelope was not received (e.g. stream was truncated). - """ - if self._result_envelope is None: - return TurnUsage() - return claude_code_usage_to_turn_usage(self._result_envelope) - - -# Runtime assert that ClaudeCodeTurn satisfies HarnessTurn protocol -assert isinstance(ClaudeCodeTurn.__new__(ClaudeCodeTurn), HarnessTurn), ( - "ClaudeCodeTurn must satisfy the HarnessTurn protocol" -) diff --git a/src/agentex/lib/adk/_modules/_codex_sync.py b/src/agentex/lib/adk/_modules/_codex_sync.py deleted file mode 100644 index 951622b13..000000000 --- a/src/agentex/lib/adk/_modules/_codex_sync.py +++ /dev/null @@ -1,640 +0,0 @@ -"""Codex event-stream parser tap for the unified harness surface. - -Converts a ``codex exec --json`` newline-delimited event stream (already -produced by the golden agent's sandbox/subprocess orchestration) into the -Agentex canonical ``StreamTaskMessage*`` events. - -SCOPE ------ -This module is a **pure parser**. It receives pre-produced codex events -(``str`` lines or already-decoded ``dict`` objects) and yields canonical -``StreamTaskMessage*`` events. All subprocess management, sandbox -provisioning, secret injection, and MCP orchestration remain in the golden -agent at -``teams/sgp/agents/golden_agent/project/harness/providers/codex.py``. - -No deployable test agent is included here: running codex requires the -golden agent's sandbox environment and is out of scope for this library tap. - -OUT OF SCOPE (document here so future callers are not surprised): -- Subprocess / sandbox management -- OPENAI_API_KEY / secret injection -- MCP server configuration (--config /tmp/codex_config.toml) -- ``codex exec resume`` session tracking -- ``scale_sandbox`` imports - -CANONICAL MAPPING ------------------ -The table below lists every ``type`` field the codex exec JSON stream can -emit (from ``codex-rs/exec/src/exec_events.rs``) and its mapping. - -Top-level event types -~~~~~~~~~~~~~~~~~~~~~ - thread.started -> (no StreamTaskMessage; session_id captured - internally; surfaced via ``on_result`` callback) - turn.started -> (no StreamTaskMessage; turn was started before - codex launched; nothing to emit here) - turn.completed -> on_result(usage_dict, tool_count, reasoning_count) - yields no StreamTaskMessage (turn lifecycle is - managed by the activity layer) - turn.failed -> StreamTaskMessageFull(TextContent, error text) - error -> StreamTaskMessageFull(TextContent, error text) - -Item sub-types (item.started / item.updated / item.completed) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - agent_message -> text deltas: - item.started / item.updated -> StreamTaskMessageDelta(TextDelta) - item.completed -> StreamTaskMessageDone - reasoning -> reasoning: - item.started -> StreamTaskMessageStart(ReasoningContent) - item.updated -> (no-op; final text arrives on completed) - item.completed -> StreamTaskMessageDelta(ReasoningSummaryDelta) - + StreamTaskMessageDelta(ReasoningContentDelta) - + StreamTaskMessageDone - command_execution -> tool request + response: - item.started -> StreamTaskMessageStart(ToolRequestContent) - + StreamTaskMessageDone - item.completed -> StreamTaskMessageFull(ToolResponseContent) - file_change -> same as command_execution - NOTE: file_change may only emit item.completed (no started); - a synthetic ToolRequestContent Full is emitted before the response. - mcp_tool_call -> same as command_execution - web_search -> same as command_execution - todo_list -> same as command_execution - collab_tool_call -> same as command_execution - error (item type) -> StreamTaskMessageFull(TextContent, error text) on completed only - -UNMAPPED / PARTIALLY MAPPED EVENTS ------------------------------------ - thread.started: session_id is extracted but not forwarded as a - StreamTaskMessage (no canonical content type for - session-lifecycle signals; captured in on_result). - turn.started: no-op; intentional (the caller owns turn lifecycle). - turn.completed: no StreamTaskMessage; usage is forwarded via - on_result so the caller can record it in a span - without this module needing to know about spans. - item.updated (reasoning): the intermediate cumulative text is discarded; - only item.completed carries the final text. - item.updated (tool): tool item types other than agent_message do not - emit updates; item.started opens the request and - item.completed closes it. -""" - -from __future__ import annotations - -import json -from typing import Any, Callable, AsyncIterator - -from agentex.lib.utils.logging import make_logger -from agentex.types.reasoning_content import ReasoningContent -from agentex.types.task_message_delta import TextDelta -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.types.task_message_content import TextContent -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.types.reasoning_content_delta import ReasoningContentDelta -from agentex.types.reasoning_summary_delta import ReasoningSummaryDelta - -logger = make_logger(__name__) - -# Canonical type alias matching the unified harness surface. -StreamTaskMessage = StreamTaskMessageStart | StreamTaskMessageDelta | StreamTaskMessageFull | StreamTaskMessageDone - -_MAX_RESULT_LENGTH = 4000 - - -def _truncate(text: str, max_len: int = _MAX_RESULT_LENGTH) -> str: - return str(text)[:max_len] - - -def _tool_name_for(item_type: str, payload: dict[str, Any]) -> str: - """Derive a canonical tool name from a codex item type.""" - if item_type == "command_execution": - return "bash" - if item_type == "file_change": - return "file_change" - if item_type == "mcp_tool_call": - server = payload.get("server", "") - tool = payload.get("tool", "") - return f"{server}.{tool}" if (server or tool) else "mcp_tool_call" - if item_type == "web_search": - return "web_search" - if item_type == "todo_list": - return "todo_list" - if item_type == "collab_tool_call": - return "collab_tool_call" - return item_type or "unknown" - - -def _tool_args_for(item_type: str, payload: dict[str, Any]) -> dict[str, Any]: - """Extract canonical arguments dict from a codex item payload.""" - if item_type == "command_execution": - return {"command": payload.get("command", "")} - if item_type == "file_change": - return {"changes": payload.get("changes") or []} - if item_type == "mcp_tool_call": - args = payload.get("arguments") - return args if isinstance(args, dict) else {"value": args} - if item_type == "web_search": - return {"query": payload.get("query", "")} - if item_type == "todo_list": - return {"items": payload.get("items") or []} - if item_type == "collab_tool_call": - # Surface an arguments dict if the payload carries one (mirrors - # mcp_tool_call); otherwise no args rather than fabricating a shape. - args = payload.get("arguments") - return args if isinstance(args, dict) else {} - return {} - - -def _tool_output_for(item_type: str, payload: dict[str, Any]) -> tuple[str, bool]: - """Extract (result_text, is_error) from a completed codex tool item.""" - if item_type == "command_execution": - out = payload.get("aggregated_output") or "" - exit_code = payload.get("exit_code") - is_error = exit_code is not None and exit_code != 0 - return _truncate(out), is_error - if item_type in ("mcp_tool_call", "collab_tool_call"): - # collab_tool_call mirrors mcp_tool_call's error/result convention - # (see _tool_args_for); without this branch a failed collab call would - # fall through to the generic path and be reported as a success. - err = payload.get("error") - if err: - msg = err.get("message", "") if isinstance(err, dict) else str(err) - return _truncate(f"Error: {msg}"), True - result = payload.get("result") - if result is None: - return "", False - try: - return _truncate(json.dumps(result)), False - except (TypeError, ValueError): - return _truncate(str(result)), False - if item_type == "file_change": - changes = payload.get("changes") or [] - status = payload.get("status", "") - return f"status={status}, {len(changes)} changes", status == "failed" - try: - return _truncate(json.dumps(payload, default=str)), False - except (TypeError, ValueError): - return _truncate(str(payload)), False - - -def _error_full(message: str, next_index: int) -> StreamTaskMessageFull: - """Emit a one-shot TextContent full message for an error.""" - return StreamTaskMessageFull( - type="full", - index=next_index, - content=TextContent( - type="text", - author="agent", - content=f"Error: {message}", - format="plain", - ), - ) - - -class _CodexStreamProcessor: - """Stateful parser: consumes codex exec events, yields StreamTaskMessage*. - - Ported from the golden agent's ``_CodexEventProcessor`` in - ``project/harness/providers/codex.py``, adapted to yield - ``StreamTaskMessage*`` directly instead of ``HarnessEvent`` objects. - - State tracked: - - ``_next_index``: monotonically increasing message index. - - ``_text_index``: message index of the current open agent_message block. - - ``_text_accumulated``: cumulative text per agent_message item_id. - - ``_reasoning_index``: message index of the current open reasoning block. - - ``_reasoning_text``: latest cumulative reasoning text per item_id. - - ``_tool_open``: item_ids for which a ToolRequestContent Start was emitted - but no ToolResponseContent Full yet. - - ``_tool_item_types``: item_id -> item_type for open tool calls. - """ - - def __init__(self) -> None: - self._next_index: int = 0 - - # agent_message tracking - self._text_index: dict[str, int] = {} - self._text_accumulated: dict[str, str] = {} - - # reasoning tracking - self._reasoning_index: dict[str, int] = {} - self._reasoning_text: dict[str, str] = {} - - # tool tracking - self._tool_open: set[str] = set() - self._tool_item_types: dict[str, str] = {} - # Remember the tool_call_id assigned per item so the request and response - # halves agree even when item_id is empty (a recomputed fallback would - # drift as tool_call_count advances between started and completed). - self._tool_call_ids: dict[str, str] = {} - - # counters for on_result callback - self.tool_call_count: int = 0 - self.reasoning_count: int = 0 - self.session_id: str | None = None - - def _alloc(self) -> int: - idx = self._next_index - self._next_index += 1 - return idx - - def process(self, evt: dict[str, Any]) -> list[StreamTaskMessage]: - evt_type = evt.get("type", "") - - if evt_type == "thread.started": - sid = evt.get("thread_id") or "" - if sid: - self.session_id = sid - return [] - - if evt_type == "turn.started": - # The activity layer owns turn lifecycle; nothing to emit. - return [] - - if evt_type == "turn.completed": - # Usage forwarded via on_result callback (not a StreamTaskMessage). - return [] - - if evt_type == "turn.failed": - err = evt.get("error") or {} - msg = err.get("message", "codex turn failed") if isinstance(err, dict) else str(err) - return [_error_full(f"Codex turn failed: {msg}", self._alloc())] - - if evt_type == "error": - return [_error_full(evt.get("message", "codex error"), self._alloc())] - - if evt_type in ("item.started", "item.updated", "item.completed"): - item = evt.get("item") or {} - return self._handle_item(evt_type, item) - - logger.debug("[codex] unhandled event type=%s", evt_type) - return [] - - def _handle_item(self, evt_type: str, item: dict[str, Any]) -> list[StreamTaskMessage]: - item_id = item.get("id") or "" - item_type = item.get("type") or "" - out: list[StreamTaskMessage] = [] - - if item_type == "agent_message": - current = item.get("text") or "" - previous = self._text_accumulated.get(item_id, "") - - if evt_type in ("item.started", "item.updated"): - if item_id not in self._text_index: - idx = self._alloc() - self._text_index[item_id] = idx - out.append( - StreamTaskMessageStart( - type="start", - index=idx, - content=TextContent( - type="text", - author="agent", - content="", - ), - ) - ) - idx = self._text_index[item_id] - delta = "" - if current.startswith(previous) and len(current) > len(previous): - delta = current[len(previous) :] - elif current and current != previous: - delta = current - if delta: - out.append( - StreamTaskMessageDelta( - type="delta", - index=idx, - delta=TextDelta(type="text", text_delta=delta), - ) - ) - self._text_accumulated[item_id] = current - - elif evt_type == "item.completed": - if item_id not in self._text_index: - idx = self._alloc() - self._text_index[item_id] = idx - out.append( - StreamTaskMessageStart( - type="start", - index=idx, - content=TextContent( - type="text", - author="agent", - content="", - ), - ) - ) - idx = self._text_index[item_id] - delta = "" - if current.startswith(previous) and len(current) > len(previous): - delta = current[len(previous) :] - elif current and current != previous: - delta = current - if delta: - out.append( - StreamTaskMessageDelta( - type="delta", - index=idx, - delta=TextDelta(type="text", text_delta=delta), - ) - ) - out.append(StreamTaskMessageDone(type="done", index=idx)) - self._text_accumulated[item_id] = current - - elif item_type == "reasoning": - current = item.get("text") or "" - - if evt_type == "item.started": - idx = self._alloc() - self._reasoning_index[item_id] = idx - self._reasoning_text[item_id] = current - out.append( - StreamTaskMessageStart( - type="start", - index=idx, - content=ReasoningContent( - type="reasoning", - author="agent", - summary=[], - content=[], - style="active", - ), - ) - ) - - elif evt_type == "item.updated": - # Accumulate silently; final text arrives on item.completed. - self._reasoning_text[item_id] = current - - elif evt_type == "item.completed": - text = current or self._reasoning_text.get(item_id, "") - idx = self._reasoning_index.get(item_id) - if text: - self.reasoning_count += 1 - summary = text.strip().split("\n", 1)[0][:300] - if idx is None: - # No started event was seen; open the message now. - idx = self._alloc() - out.append( - StreamTaskMessageStart( - type="start", - index=idx, - content=ReasoningContent( - type="reasoning", - author="agent", - summary=[], - content=[], - style="active", - ), - ) - ) - # Deliver the reasoning as deltas, then close with a Done. - # Emitting a Full here instead would leave the open Start - # context dangling: auto_send routes Full into its own - # throwaway streaming context (ignoring the index), so the - # Start context survives until end-of-turn teardown and - # persists a second, near-empty reasoning message. Streaming - # the content as deltas lets the open context accumulate the - # final ReasoningContent and close cleanly as one message. - out.append( - StreamTaskMessageDelta( - type="delta", - index=idx, - delta=ReasoningSummaryDelta( - type="reasoning_summary", - summary_index=0, - summary_delta=summary, - ), - ) - ) - out.append( - StreamTaskMessageDelta( - type="delta", - index=idx, - delta=ReasoningContentDelta( - type="reasoning_content", - content_index=0, - content_delta=text, - ), - ) - ) - out.append(StreamTaskMessageDone(type="done", index=idx)) - elif idx is not None: - # Empty reasoning block โ€” still need to close with a Done. - out.append(StreamTaskMessageDone(type="done", index=idx)) - - elif item_type in ( - "command_execution", - "file_change", - "mcp_tool_call", - "web_search", - "todo_list", - "collab_tool_call", - ): - # Resolve a stable id once per item; reuse it for both halves. - tool_call_id = self._tool_call_ids.get(item_id) - if tool_call_id is None: - tool_call_id = item_id or f"codex_tool_{self.tool_call_count + 1}" - self._tool_call_ids[item_id] = tool_call_id - - if evt_type == "item.started": - self.tool_call_count += 1 - self._tool_open.add(item_id) - self._tool_item_types[item_id] = item_type - name = _tool_name_for(item_type, item) - args = _tool_args_for(item_type, item) - req_idx = self._alloc() - out.append( - StreamTaskMessageStart( - type="start", - index=req_idx, - content=ToolRequestContent( - type="tool_request", - author="agent", - tool_call_id=tool_call_id, - name=name, - arguments=args, - ), - ) - ) - out.append(StreamTaskMessageDone(type="done", index=req_idx)) - - elif evt_type == "item.completed": - # file_change items may only emit item.completed (no started). - if item_id not in self._tool_open: - self.tool_call_count += 1 - self._tool_open.add(item_id) - self._tool_item_types[item_id] = item_type - name = _tool_name_for(item_type, item) - args = _tool_args_for(item_type, item) - req_idx = self._alloc() - out.append( - StreamTaskMessageFull( - type="full", - index=req_idx, - content=ToolRequestContent( - type="tool_request", - author="agent", - tool_call_id=tool_call_id, - name=name, - arguments=args, - ), - ) - ) - - actual_type = self._tool_item_types.get(item_id, item_type) - result_text, is_error = _tool_output_for(actual_type, item) - name = _tool_name_for(actual_type, item) - resp_content: dict[str, Any] = {"result": result_text} - if is_error: - resp_content["is_error"] = True - out.append( - StreamTaskMessageFull( - type="full", - index=self._alloc(), - content=ToolResponseContent( - type="tool_response", - author="agent", - tool_call_id=tool_call_id, - name=name, - content=resp_content, - ), - ) - ) - self._tool_open.discard(item_id) - # Free the id mapping so a later item reusing an empty id gets a - # fresh fallback rather than colliding with this one. - self._tool_call_ids.pop(item_id, None) - - elif item_type == "error": - if evt_type == "item.completed": - out.append(_error_full(item.get("message", "codex item error"), self._alloc())) - - else: - logger.debug("[codex] unhandled item type=%s evt=%s", item_type, evt_type) - - return out - - -async def convert_codex_to_agentex_events( - events: AsyncIterator[str | dict[str, Any]], - on_result: Callable[[dict[str, Any]], None] | None = None, - on_init: Callable[[dict[str, Any]], None] | None = None, -) -> AsyncIterator[StreamTaskMessage]: - """Public tap: convert a ``codex exec --json`` event stream to events. - - Thin wrapper over :func:`_convert_codex_impl` that owns the cancellation - backstop: a ``finally`` closes the underlying ``events`` iterator (when it - exposes ``aclose``) whenever this generator is closed โ€” including on the - ``GeneratorExit``/``CancelledError`` raised when a consuming task is - cancelled mid-turn by an interrupt. This terminates the CLI stdout handle / - subprocess instead of leaking it. - """ - inner = _convert_codex_impl(events, on_result=on_result, on_init=on_init) - try: - async for event in inner: - yield event - finally: - inner_aclose = getattr(inner, "aclose", None) - if inner_aclose is not None: - await inner_aclose() - aclose = getattr(events, "aclose", None) - if aclose is not None: - await aclose() - - -async def _convert_codex_impl( - events: AsyncIterator[str | dict[str, Any]], - on_result: Callable[[dict[str, Any]], None] | None = None, - on_init: Callable[[dict[str, Any]], None] | None = None, -) -> AsyncIterator[StreamTaskMessage]: - """Convert a ``codex exec --json`` event stream into Agentex stream events. - - This is a pure parser tap. The caller must supply ``events`` as an async - iterator of either raw newline-delimited JSON strings or pre-decoded dicts. - No subprocess or sandbox management is done here. - - Args: - events: Async iterator of ``str`` (newline-delimited JSON lines) or - ``dict`` (pre-decoded event objects) as produced by the codex CLI's - ``--json`` flag via sandbox stdout. - on_result: Optional callback invoked once when a ``turn.completed`` - event is seen. Receives a dict with keys: - ``usage`` โ€” the raw codex usage dict (or None) - ``session_id`` โ€” the codex thread_id (or None) - ``tool_call_count`` โ€” int - ``reasoning_count`` โ€” int - Use this to record turn-level metrics / usage in the caller's span - without coupling this module to span/tracing APIs. - on_init: Optional callback invoked once when the ``thread.started`` event - is seen (the first event of a codex stream). Receives ``{"session_id": - }``. This surfaces the session id EARLY โ€” before - the turn completes โ€” so a turn interrupted before completion is still - resumable (``turn.completed`` / ``on_result`` never fires then). The - codex counterpart of the claude-code ``system/init`` early capture. - - Yields: - Canonical ``StreamTaskMessage*`` events (Start/Delta/Full/Done) with - ``TextContent``, ``ReasoningContent``, ``ToolRequestContent``, or - ``ToolResponseContent`` payloads. - - MAPPING (abbreviated โ€” see module docstring for the full table) - thread.started -> no event; session_id captured for on_result - turn.started -> no event - turn.completed -> no event; triggers on_result callback - turn.failed / error -> StreamTaskMessageFull(TextContent, error) - agent_message -> Start + Deltas + Done - reasoning -> Start + Full(ReasoningContent) - command_execution -> Start(ToolRequest)+Done + Full(ToolResponse) - file_change -> Full(ToolRequest) + Full(ToolResponse) - mcp_tool_call -> Start(ToolRequest)+Done + Full(ToolResponse) - web_search / todo_list -> Start(ToolRequest)+Done + Full(ToolResponse) - collab_tool_call -> Start(ToolRequest)+Done + Full(ToolResponse) - """ - processor = _CodexStreamProcessor() - _pending_usage: dict[str, Any] | None = None - - async for raw in events: - if isinstance(raw, dict): - evt = raw - else: - line = raw.strip() if isinstance(raw, str) else "" - if not line: - continue - try: - evt = json.loads(line) - except json.JSONDecodeError: - logger.debug("[codex] non-JSON line: %s", line[:100]) - continue - - # Capture usage before processing so on_result can fire after flush. - if evt.get("type") == "turn.completed": - usage = evt.get("usage") - _pending_usage = usage if isinstance(usage, dict) else None - - messages = processor.process(evt) - for msg in messages: - yield msg - - # Surface session_id early (processor sets it while handling - # thread.started) so an interrupted turn is still resumable. - if on_init is not None and evt.get("type") == "thread.started": - on_init({"session_id": processor.session_id}) - - if on_result is not None: - on_result( - { - "usage": _pending_usage, - "session_id": processor.session_id, - "tool_call_count": processor.tool_call_count, - "reasoning_count": processor.reasoning_count, - } - ) diff --git a/src/agentex/lib/adk/_modules/_codex_turn.py b/src/agentex/lib/adk/_modules/_codex_turn.py deleted file mode 100644 index 05429bce5..000000000 --- a/src/agentex/lib/adk/_modules/_codex_turn.py +++ /dev/null @@ -1,228 +0,0 @@ -"""CodexTurn: HarnessTurn implementation for the codex event-stream tap. - -Wraps ``convert_codex_to_agentex_events`` so callers can pass a ``CodexTurn`` -directly to ``UnifiedEmitter.yield_turn`` or ``UnifiedEmitter.auto_send_turn``. - -Usage:: - - from agentex.lib.adk import convert_codex_to_agentex_events - from agentex.lib.adk._modules._codex_turn import CodexTurn, codex_usage_to_turn_usage - - turn = CodexTurn(events=codex_event_stream, model="o4-mini") - async for msg in emitter.yield_turn(turn): - yield msg - turn_usage = turn.usage() - -OUT OF SCOPE ------------- -Like ``_codex_sync``, this module is a pure library tap. Subprocess -provisioning, sandbox setup, secret injection, and MCP configuration remain -in the golden agent (``teams/sgp/agents/golden_agent/project/harness/``). -""" - -from __future__ import annotations - -from typing import Any, AsyncIterator - -from agentex.lib.core.harness.types import TurnUsage -from agentex.lib.adk._modules._codex_sync import ( - StreamTaskMessage, - convert_codex_to_agentex_events, -) - - -def codex_usage_to_turn_usage( - raw: dict[str, Any] | None, - *, - model: str | None = None, - tool_call_count: int = 0, - reasoning_count: int = 0, - duration_ms: int | None = None, - cost_usd: float | None = None, -) -> TurnUsage: - """Map a raw codex ``turn.completed`` usage dict to a canonical ``TurnUsage``. - - Codex reports token usage under the ``usage`` key of the - ``turn.completed`` event. The shape follows the OpenAI completion_tokens - convention because codex is built on OpenAI models: - - .. code-block:: json - - { - "input_tokens": 1234, - "output_tokens": 456, - "total_tokens": 1690 - } - - Additionally, codex may report ``reasoning_tokens`` for o-series models: - - .. code-block:: json - - { - "input_tokens": 1234, - "output_tokens": 456, - "reasoning_tokens": 200, - "total_tokens": 1690 - } - - Defensive rules: - - Missing ``raw`` or missing sub-keys default to ``None`` (not zero) so - downstream callers can distinguish "not reported" from "reported as 0". - - Real zeros (``0`` explicitly present in ``raw``) are preserved as ``0``. - - ``total_tokens`` is accepted from the payload or left as ``None``; - callers should not recompute it because codex may use cached tokens. - - ``cost_usd`` is passed through when codex reports it (not yet common); - defaults to ``None`` if absent. - - Args: - raw: The raw codex usage dict from ``turn.completed``, or ``None``. - model: Model string (e.g. "o4-mini") to attach to the usage record. - tool_call_count: Number of tool calls in the turn (from processor). - reasoning_count: Number of reasoning blocks (from processor). - duration_ms: Wall-clock duration of the turn in milliseconds. - cost_usd: Cost in USD if the caller can derive it; ``None`` otherwise. - - Returns: - A populated ``TurnUsage`` instance. - """ - if not isinstance(raw, dict): - raw = {} - - def _int_or_none(key: str) -> int | None: - val = raw.get(key) - if val is None: - return None - try: - return int(val) - except (TypeError, ValueError): - return None - - def _float_or_none(key: str) -> float | None: - val = raw.get(key) - if val is None: - return None - try: - return float(val) - except (TypeError, ValueError): - return None - - # cost_usd: prefer explicitly passed value, then fall back to raw payload. - effective_cost = cost_usd if cost_usd is not None else _float_or_none("cost_usd") - - return TurnUsage( - model=model or None, - input_tokens=_int_or_none("input_tokens"), - output_tokens=_int_or_none("output_tokens"), - cached_input_tokens=_int_or_none("cached_input_tokens"), - reasoning_tokens=_int_or_none("reasoning_tokens"), - total_tokens=_int_or_none("total_tokens"), - cost_usd=effective_cost, - duration_ms=duration_ms, - num_llm_calls=1, - num_tool_calls=tool_call_count, - num_reasoning_blocks=reasoning_count, - ) - - -class CodexTurn: - """A single codex turn as a ``HarnessTurn``. - - Implements the ``HarnessTurn`` protocol so it can be passed to - ``UnifiedEmitter.yield_turn`` and ``UnifiedEmitter.auto_send_turn``. - - ``usage()`` is valid only after ``events`` has been fully consumed (i.e. - the async generator has been exhausted). Calling ``usage()`` before - exhaustion returns a zero-value ``TurnUsage`` with only ``model`` set. - - Args: - events: An async iterator of ``str | dict`` codex events, as - produced by reading ``codex exec --json`` stdout line by line. - model: Model string to attach to the ``TurnUsage``. - duration_ms: Optional turn wall-clock duration in milliseconds. - cost_usd: Optional cost in USD; ``None`` if not known. - """ - - def __init__( - self, - events: AsyncIterator[str | dict[str, Any]], - *, - model: str | None = None, - duration_ms: int | None = None, - cost_usd: float | None = None, - ) -> None: - self._raw_events = events - self._model = model - # Public + mutable: the true wall-clock duration (and cost) is usually - # only known after the stream is consumed, so callers may set these - # after construction and before calling usage(). - self.duration_ms = duration_ms - self.cost_usd = cost_usd - - # Populated by the on_result callback once the stream is exhausted. - self._result: dict[str, Any] | None = None - # Populated by the on_init callback when thread.started arrives (early), - # so session_id is available even if the turn is interrupted before - # completion (turn.completed / on_result never fires then). - self._init_session_id: str | None = None - # The events generator is created at most once: ``_raw_events`` is a - # single-consumption AsyncIterator, so re-wrapping it would yield an - # already-exhausted stream that fires on_result with zeros and clobbers - # ``_result``. Cache the generator and hand back the same instance. - self._events_gen: AsyncIterator[StreamTaskMessage] | None = None - - @property - def events(self) -> AsyncIterator[StreamTaskMessage]: - """Async iterator of canonical ``StreamTaskMessage*`` events. - - The ``on_result`` callback populates ``_result`` when the underlying - codex stream ends, so ``usage()`` returns meaningful data after - exhaustion. Returns the same generator on every access so the underlying - stream is consumed (and ``on_result`` fires) exactly once. - """ - if self._events_gen is None: - self._events_gen = convert_codex_to_agentex_events( - self._raw_events, - on_result=self._on_result, - on_init=self._on_init, - ) - return self._events_gen - - def _on_result(self, result: dict[str, Any]) -> None: - self._result = result - - def _on_init(self, init: dict[str, Any]) -> None: - sid = init.get("session_id") - if sid: - self._init_session_id = sid - - @property - def session_id(self) -> str | None: - """The codex session id, for resuming a multi-turn session. - - Prefers the id from the terminal ``turn.completed`` result (fully-complete - turn), then falls back to the id captured early from ``thread.started``. - The early fallback keeps a turn interrupted before completion resumable - (no ``turn.completed`` / ``on_result`` fires then). Returns ``None`` only - if neither was seen or codex reported no session id. - """ - if self._result and self._result.get("session_id"): - return self._result.get("session_id") - return self._init_session_id - - def usage(self) -> TurnUsage: - """Return normalized ``TurnUsage`` for this turn. - - Valid only after ``events`` has been fully consumed. Returns a - zero-value ``TurnUsage`` (model set, counts zero, tokens None) if - called before the stream ends. - """ - if self._result is None: - return TurnUsage(model=self._model) - return codex_usage_to_turn_usage( - self._result.get("usage"), - model=self._model, - tool_call_count=self._result.get("tool_call_count", 0), - reasoning_count=self._result.get("reasoning_count", 0), - duration_ms=self.duration_ms, - cost_usd=self.cost_usd, - ) diff --git a/src/agentex/lib/adk/_modules/_http_checkpointer.py b/src/agentex/lib/adk/_modules/_http_checkpointer.py 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_sync.py b/src/agentex/lib/adk/_modules/_langgraph_sync.py deleted file mode 100644 index 02f2ba416..000000000 --- a/src/agentex/lib/adk/_modules/_langgraph_sync.py +++ /dev/null @@ -1,373 +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. - -Unified sync path ------------------ -Prefer using ``LangGraphTurn`` with ``UnifiedEmitter.yield_turn`` for new -agents, which adds usage capture and optional tracing via the shared harness -surface:: - - from agentex.lib.core.harness.emitter import UnifiedEmitter - from agentex.lib.adk._modules._langgraph_turn import LangGraphTurn - - turn = LangGraphTurn(stream) - emitter = UnifiedEmitter(task_id=task_id, trace_id=trace_id, parent_span_id=span_id) - async for event in emitter.yield_turn(turn): - yield event - -``convert_langgraph_to_agentex_events`` remains available as a lower-level -primitive (e.g. for callers that need the raw event stream without the -harness envelope). -""" - -from __future__ import annotations - -from typing import Any, Callable, Optional -from collections.abc import AsyncGenerator - - -async def convert_langgraph_to_agentex_events( - stream: Any, - on_final_ai_message: Optional[Callable[..., None]] = None, -) -> AsyncGenerator[Any, None]: - """Public LangGraph tap: convert events, closing the source stream on exit. - - Thin wrapper over ``_convert_langgraph_impl`` that adds a cancellation-safe - ``finally`` so an interrupted turn tears down the LangGraph ``astream`` source - instead of leaking it. - """ - inner = _convert_langgraph_impl(stream, on_final_ai_message=on_final_ai_message) - try: - async for event in inner: - yield event - finally: - for _src in (inner, stream): - _aclose = getattr(_src, "aclose", None) - if _aclose is not None: - await _aclose() - - -async def _convert_langgraph_impl( - stream: Any, - on_final_ai_message: Optional[Callable[..., None]] = None, -) -> AsyncGenerator[Any, None]: - """Convert LangGraph streaming events to Agentex TaskMessageUpdate events. - - Expects the stream from graph.astream() called with - stream_mode=["messages", "updates"]. This produces two event types: - - ("messages", (message_chunk, metadata)) โ€” token-by-token LLM output - ("updates", {node_name: state_update}) โ€” complete node outputs - - Text tokens are streamed as Start/Delta/Done sequences. - Reasoning tokens are streamed as Start/Delta/Done sequences with ReasoningContentDelta. - Tool calls and tool results are emitted as Full messages. - - Supports both regular models (chunk.content is a str) and reasoning models - like gpt-5/o1/o3 (chunk.content is a list of typed content blocks). - - LangGraph emits tool requests as ``StreamTaskMessageFull`` (from "updates" - events), NOT Start+Delta+Done like pydantic-ai. No coalesce_tool_requests - option is needed for LangGraph. - - Args: - stream: Async iterator from graph.astream(..., stream_mode=["messages", "updates"]) - on_final_ai_message: Optional callback ``(msg: AIMessage) -> None`` called for - each ``AIMessage`` in an "agent" node update. Use this to capture - ``usage_metadata`` for token accounting without re-traversing the stream. - The callback fires *after* all events for that message are yielded. - No-op when ``None`` (default). - - Yields: - TaskMessageUpdate events (Start, Delta, Done, Full) - """ - # Lazy imports so langgraph/langchain aren't required at module load time - from langchain_core.messages import ToolMessage, AIMessageChunk - - from agentex.types.text_content import TextContent - from agentex.types.reasoning_content import ReasoningContent - from agentex.types.task_message_delta import TextDelta - from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageDelta, - StreamTaskMessageStart, - ) - from agentex.types.tool_request_content import ToolRequestContent - from agentex.types.tool_response_content import ToolResponseContent - from agentex.types.reasoning_content_delta import ReasoningContentDelta - from agentex.types.reasoning_summary_delta import ReasoningSummaryDelta - - message_index = 0 - text_streaming = False - reasoning_streaming = False - reasoning_content_index = 0 - - async for event_type, event_data in stream: - if event_type == "messages": - chunk, metadata = event_data - - if not isinstance(chunk, AIMessageChunk) or not chunk.content: - continue - - # ---------------------------------------------------------- - # Case 1: content is a plain string (regular models) - # ---------------------------------------------------------- - if isinstance(chunk.content, str): - # Close reasoning stream if we're transitioning to text - if reasoning_streaming: - yield StreamTaskMessageDone(type="done", index=message_index) - reasoning_streaming = False - message_index += 1 - - if not text_streaming: - yield StreamTaskMessageStart( - type="start", - index=message_index, - content=TextContent(type="text", author="agent", content=""), - ) - text_streaming = True - - yield StreamTaskMessageDelta( - type="delta", - index=message_index, - delta=TextDelta(type="text", text_delta=chunk.content), - ) - - # ---------------------------------------------------------- - # Case 2: content is a list of typed blocks (reasoning models) - # Responses API (responses/v1) format: - # {"type": "reasoning", "summary": [{"type": "summary_text", "text": "..."}]} - # {"type": "text", "text": "..."} - # ---------------------------------------------------------- - elif isinstance(chunk.content, list): - for block in chunk.content: - if not isinstance(block, dict): - continue - - block_type = block.get("type") - - if block_type == "reasoning": - # Responses API: reasoning text is inside summary list - reasoning_text = "" - summaries = block.get("summary", []) - for s in summaries: - if isinstance(s, dict) and s.get("type") == "summary_text": - reasoning_text += s.get("text", "") - if not reasoning_text: - continue - - # Close text stream if transitioning to reasoning - if text_streaming: - yield StreamTaskMessageDone(type="done", index=message_index) - text_streaming = False - message_index += 1 - - if not reasoning_streaming: - yield StreamTaskMessageStart( - type="start", - index=message_index, - content=ReasoningContent( - type="reasoning", author="agent", summary=[], content=[], style="active" - ), - ) - reasoning_streaming = True - reasoning_content_index = 0 - - yield StreamTaskMessageDelta( - type="delta", - index=message_index, - delta=ReasoningContentDelta( - type="reasoning_content", - content_index=reasoning_content_index, - content_delta=reasoning_text, - ), - ) - - elif block_type == "text": - text_delta = block.get("text", "") - if not text_delta: - continue - - # Close reasoning stream if transitioning to text - if reasoning_streaming: - yield StreamTaskMessageDone(type="done", index=message_index) - reasoning_streaming = False - reasoning_content_index += 1 - message_index += 1 - - if not text_streaming: - yield StreamTaskMessageStart( - type="start", - index=message_index, - content=TextContent(type="text", author="agent", content=""), - ) - text_streaming = True - - yield StreamTaskMessageDelta( - type="delta", - index=message_index, - delta=TextDelta(type="text", text_delta=text_delta), - ) - - # ---------------------------------------------------------- - # Reasoning summaries via additional_kwargs (OpenAI v0.3 format) - # ---------------------------------------------------------- - additional_kwargs = getattr(chunk, "additional_kwargs", {}) - reasoning_kw = additional_kwargs.get("reasoning") - if isinstance(reasoning_kw, dict): - summaries = reasoning_kw.get("summary", []) - for si, summary_item in enumerate(summaries): - if isinstance(summary_item, dict) and summary_item.get("type") == "summary_text": - summary_text = summary_item.get("text", "") - if summary_text: - yield StreamTaskMessageDelta( - type="delta", - index=message_index, - delta=ReasoningSummaryDelta( - type="reasoning_summary", - summary_index=si, - summary_delta=summary_text, - ), - ) - - elif event_type == "updates": - for node_name, state_update in event_data.items(): - if node_name == "agent": - messages = state_update.get("messages", []) - for msg in messages: - # Close any open streams - if text_streaming: - yield StreamTaskMessageDone(type="done", index=message_index) - text_streaming = False - message_index += 1 - if reasoning_streaming: - yield StreamTaskMessageDone(type="done", index=message_index) - reasoning_streaming = False - message_index += 1 - - # Emit tool requests if the agent decided to call tools - if hasattr(msg, "tool_calls") and msg.tool_calls: - for tc in msg.tool_calls: - yield StreamTaskMessageFull( - type="full", - index=message_index, - content=ToolRequestContent( - tool_call_id=tc["id"], - name=tc["name"], - arguments=tc["args"], - author="agent", - ), - ) - message_index += 1 - - # Notify caller of the final AIMessage (e.g. for usage capture) - if on_final_ai_message is not None: - from langchain_core.messages import AIMessage as _AIMessage - - if isinstance(msg, _AIMessage): - on_final_ai_message(msg) - - elif node_name == "tools": - messages = state_update.get("messages", []) - for msg in messages: - if isinstance(msg, ToolMessage): - yield StreamTaskMessageFull( - type="full", - index=message_index, - content=ToolResponseContent( - tool_call_id=msg.tool_call_id, - name=msg.name or "unknown", - content=msg.content if isinstance(msg.content, str) else str(msg.content), - author="agent", - ), - ) - message_index += 1 - - # Close any remaining open streams - if text_streaming: - yield StreamTaskMessageDone(type="done", index=message_index) - if reasoning_streaming: - yield StreamTaskMessageDone(type="done", index=message_index) - - -async def emit_langgraph_messages(messages: list[Any], task_id: str) -> str: - """Create Agentex messages for a list of LangGraph messages. - - This is the non-streaming counterpart to ``stream_langgraph_events``. Use it - when you run a LangGraph graph with ``ainvoke`` (for example a Temporal-backed - agent using the LangGraph plugin, where streaming deltas aren't available) and - want to surface the resulting messages to the Agentex UI after the fact. - - It maps LangGraph/LangChain message objects to Agentex content types: - - - ``AIMessage`` tool calls -> ``ToolRequestContent`` (one per call) - - ``AIMessage`` text content -> ``TextContent`` - - ``ToolMessage`` -> ``ToolResponseContent`` - - Pass only the messages produced this turn (e.g. ``messages[already_emitted:]``) - so each message is surfaced exactly once across a multi-turn conversation. - - Args: - messages: LangGraph/LangChain message objects to surface โ€” typically - the new messages a turn produced. - task_id: The Agentex task to create messages on. - - Returns: - The last assistant text emitted (useful as a span/turn output), or "". - """ - # Lazy imports so langchain isn't required at module load time. - from langchain_core.messages import AIMessage, ToolMessage - - from agentex.lib import adk - from agentex.types.text_content import TextContent - from agentex.types.tool_request_content import ToolRequestContent - from agentex.types.tool_response_content import ToolResponseContent - - final_text = "" - for message in messages: - if isinstance(message, AIMessage): - for tool_call in message.tool_calls or []: - await adk.messages.create( - task_id=task_id, - content=ToolRequestContent( - author="agent", - tool_call_id=tool_call["id"], - name=tool_call["name"], - arguments=tool_call["args"], - ), - ) - # ``content`` may be a plain string (OpenAI) or a list of content - # blocks (Anthropic/Claude via LangChain, e.g. - # ``[{"type": "text", "text": "..."}]``). Extract and join the text - # so the response is visible regardless of the underlying model. - if isinstance(message.content, str): - text = message.content - else: - text = "".join( - block.get("text", "") if isinstance(block, dict) else str(block) - for block in message.content - if not isinstance(block, dict) or block.get("type") == "text" - ) - if text: - final_text = text - await adk.messages.create( - task_id=task_id, - content=TextContent(author="agent", content=text, format="markdown"), - ) - elif isinstance(message, ToolMessage): - await adk.messages.create( - task_id=task_id, - content=ToolResponseContent( - author="agent", - tool_call_id=message.tool_call_id, - name=message.name or "unknown", - content=message.content - if isinstance(message.content, str) - else str(message.content), - ), - ) - return final_text diff --git a/src/agentex/lib/adk/_modules/_langgraph_turn.py b/src/agentex/lib/adk/_modules/_langgraph_turn.py deleted file mode 100644 index a6e290e1b..000000000 --- a/src/agentex/lib/adk/_modules/_langgraph_turn.py +++ /dev/null @@ -1,200 +0,0 @@ -"""HarnessTurn adapter for LangGraph astream() event streams. - -Provides ``LangGraphTurn`` (a ``HarnessTurn`` implementation) and the -``langgraph_usage_to_turn_usage`` helper that maps LangGraph's -``AIMessage.usage_metadata`` onto the framework-agnostic ``TurnUsage`` model. - -LangGraph emits tool requests as ``StreamTaskMessageFull`` events (from -"updates" events), NOT Start+Delta+Done like pydantic-ai. ``auto_send`` handles -Full events correctly; no coalescing wrapper is needed. -""" - -from __future__ import annotations - -from typing import Any, AsyncIterator -from collections.abc import AsyncGenerator - -from agentex.lib.utils.temporal import workflow_now_if_in_workflow -from agentex.lib.core.harness.types import TurnUsage, StreamTaskMessage -from agentex.lib.adk._modules._langgraph_sync import convert_langgraph_to_agentex_events - - -def langgraph_usage_to_turn_usage(usage_metadata: Any, model: str | None) -> TurnUsage: - """Map LangGraph ``AIMessage.usage_metadata`` onto ``TurnUsage``. - - ``usage_metadata`` may be ``None`` (model doesn't report usage). - Real zero token counts (e.g. 0 output tokens) are preserved as 0, NOT - coerced to ``None``. - - Mapping:: - - input_tokens -> input_tokens - output_tokens -> output_tokens - total_tokens -> total_tokens - input_token_details.cache_read -> cached_input_tokens - output_token_details.reasoning -> reasoning_tokens - - Args: - usage_metadata: The ``usage_metadata`` dict from an ``AIMessage``, - or ``None`` if the model did not report usage. - model: The model name string to attach to the ``TurnUsage``, or ``None``. - - Returns: - A populated ``TurnUsage`` instance. - """ - if usage_metadata is None: - return TurnUsage(model=model) - - raw_input = (usage_metadata or {}).get("input_tokens") - raw_output = (usage_metadata or {}).get("output_tokens") - raw_total = (usage_metadata or {}).get("total_tokens") - input_details = (usage_metadata or {}).get("input_token_details") or {} - output_details = (usage_metadata or {}).get("output_token_details") or {} - raw_cache_read = input_details.get("cache_read") - raw_reasoning = output_details.get("reasoning") - - return TurnUsage( - model=model, - input_tokens=raw_input, - output_tokens=raw_output, - total_tokens=raw_total, - cached_input_tokens=raw_cache_read, - reasoning_tokens=raw_reasoning, - ) - - -def _add_optional(a: int | None, b: int | None) -> int | None: - """Sum two optional token counts; ``None`` means 'not reported' on that side. - - ``None + None`` stays ``None`` (model never reported usage), while a real 0 - contributes 0 (preserving zero counts rather than coercing them away). - """ - if a is None and b is None: - return None - return (a or 0) + (b or 0) - - -def _accumulate_turn_usage(acc: TurnUsage, call: TurnUsage, model: str | None) -> TurnUsage: - """Add a single LLM call's usage into the running per-turn total. - - A LangGraph turn can make multiple LLM calls (e.g. text -> tool decision -> - final text); summing them avoids silently dropping all but the last call. - """ - return TurnUsage( - model=model, - input_tokens=_add_optional(acc.input_tokens, call.input_tokens), - output_tokens=_add_optional(acc.output_tokens, call.output_tokens), - total_tokens=_add_optional(acc.total_tokens, call.total_tokens), - cached_input_tokens=_add_optional(acc.cached_input_tokens, call.cached_input_tokens), - reasoning_tokens=_add_optional(acc.reasoning_tokens, call.reasoning_tokens), - ) - - -class LangGraphTurn: - """HarnessTurn wrapping a LangGraph ``astream()`` event stream. - - Implements the ``HarnessTurn`` Protocol so it can be passed to either - ``UnifiedEmitter.yield_turn`` (sync HTTP ACP) or - ``UnifiedEmitter.auto_send_turn`` (async / temporal). - - Usage:: - - stream = graph.astream( - {"messages": [{"role": "user", "content": user_message}]}, - stream_mode=["messages", "updates"], - ) - turn = LangGraphTurn(stream, model=model_name) - - # Sync HTTP ACP - async for event in emitter.yield_turn(turn): - yield event - - # Async / temporal - result = await emitter.auto_send_turn(turn) - - LangGraph tool requests are ``StreamTaskMessageFull`` (from "updates"), NOT - Start+Delta+Done like pydantic-ai. No ``coalesce_tool_requests`` option is - needed. - - Usage data is captured lazily via the ``on_final_ai_message`` callback and - is only valid after ``events`` has been fully consumed. Multi-step turns - (more than one LLM call) accumulate usage additively across calls. - """ - - def __init__(self, stream: Any, model: str | None = None) -> None: - self._stream = stream - self._model = model - self._usage: TurnUsage = TurnUsage(model=model) - - @property - def events(self) -> AsyncIterator[StreamTaskMessage]: - return self._generate_events() - - async def _generate_events(self) -> AsyncGenerator[StreamTaskMessage, None]: - def _capture(ai_msg: Any) -> None: - usage_metadata = getattr(ai_msg, "usage_metadata", None) - if usage_metadata is not None: - call_usage = langgraph_usage_to_turn_usage(usage_metadata, self._model) - # Accumulate across LLM calls โ€” the callback fires once per agent - # node invocation, so a multi-step turn reports usage more than - # once; overwriting would drop all but the last call. - self._usage = _accumulate_turn_usage(self._usage, call_usage, self._model) - - async for ev in convert_langgraph_to_agentex_events(self._stream, on_final_ai_message=_capture): - yield ev - - def usage(self) -> TurnUsage: - """Return the usage accumulated across all AIMessages in the stream. - - Multi-step turns sum each LLM call's usage. Valid only after ``events`` - has been fully consumed. Returns a zero-usage ``TurnUsage`` if the model - did not report usage. - """ - return self._usage - - -async def stream_langgraph_events(stream, task_id: str) -> str: - """Stream LangGraph events to Agentex via Redis. - - Converts LangGraph ``graph.astream()`` events into Agentex streaming - updates and pushes them to Redis via ``adk.streaming`` contexts. For use - with async ACP agents that stream via Redis rather than HTTP yields. - - Processes the stream from graph.astream() called with - stream_mode=["messages", "updates"] and pushes text, reasoning, - tool request, and tool response messages through Redis streaming - contexts. - - Supports both regular models (chunk.content is a str) and reasoning - models like gpt-5/o1/o3 (chunk.content is a list of typed content blocks - in the Responses API responses/v1 format). - - Implemented on ``UnifiedEmitter.auto_send_turn(LangGraphTurn(...))`` for - cross-harness consistency, the same surface used by every other harness - adapter (pydantic-ai, openai-agents, etc.). The public signature and - return type are preserved identically. - - LangGraph emits tool requests as ``Full`` events (from "updates"), NOT - Start+Delta+Done like pydantic-ai. ``auto_send`` handles Full events - correctly; no coalescing wrapper is needed. - - ``created_at`` is set from ``workflow.now()`` when called inside a - Temporal workflow, matching the pattern used by the openai/litellm providers. - Outside a workflow (plain async activities, sync agents) it is ``None`` and the - server's wall clock is used. - - Args: - stream: Async iterator from graph.astream(..., stream_mode=["messages", "updates"]) - task_id: The Agentex task ID to stream messages to. - - Returns: - The accumulated final text output from the agent. - """ - from agentex.lib.core.harness.emitter import UnifiedEmitter - - # Stamp messages with workflow.now() inside Temporal for deterministic - # created_at ordering; falls back to None (server wall clock) outside a workflow. - turn = LangGraphTurn(stream, model=None) - emitter = UnifiedEmitter(task_id=task_id, trace_id=None, parent_span_id=None) - result = await emitter.auto_send_turn(turn, created_at=workflow_now_if_in_workflow()) - return result.final_text diff --git a/src/agentex/lib/adk/_modules/_openai_sync.py b/src/agentex/lib/adk/_modules/_openai_sync.py deleted file mode 100644 index b857a5be6..000000000 --- a/src/agentex/lib/adk/_modules/_openai_sync.py +++ /dev/null @@ -1,395 +0,0 @@ -"""Sync OpenAI Agents SDK streaming tap for Agentex. - -Converts an OpenAI Agents SDK streamed run (``Runner.run_streamed(...)`` -``stream_events()``) into Agentex ``StreamTaskMessage*`` events, including -reasoning content and reasoning summary deltas for reasoning models (o1/o3/gpt-5). - -This is the lower-level primitive used by ``OpenAITurn`` (in -``_openai_turn.py``). New OpenAI Agents integrations should prefer wrapping a -``Runner.run_streamed`` result in ``OpenAITurn`` and driving delivery + tracing -through ``UnifiedEmitter``. -""" - -from __future__ import annotations - -import json -from typing import Any - -from openai.types.responses import ( - ResponseTextDeltaEvent, - ResponseFunctionToolCall, - ResponseFunctionWebSearch, - ResponseOutputItemDoneEvent, - ResponseOutputItemAddedEvent, - ResponseCodeInterpreterToolCall, - ResponseReasoningSummaryPartAddedEvent, - ResponseReasoningSummaryTextDeltaEvent, -) -from openai.types.responses.response_reasoning_text_done_event import ResponseReasoningTextDoneEvent -from openai.types.responses.response_reasoning_text_delta_event import ResponseReasoningTextDeltaEvent -from openai.types.responses.response_reasoning_summary_text_done_event import ResponseReasoningSummaryTextDoneEvent - -from agentex.types.reasoning_content import ReasoningContent -from agentex.types.task_message_delta import TextDelta -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.types.task_message_content import TextContent -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.types.reasoning_content_delta import ReasoningContentDelta -from agentex.types.reasoning_summary_delta import ReasoningSummaryDelta - - -def _safe_parse_arguments(arguments: Any) -> dict[str, Any]: - """Coerce a tool call's ``arguments`` into a dict, tolerating bad JSON. - - ``ToolRequestContent.arguments`` is typed ``Dict[str, object]``, so the - result is ALWAYS a dict โ€” a non-dict payload must not abort the turn. - Mirroring the Temporal streaming model: malformed/truncated strings are - preserved under ``raw``, and any other non-dict value (a list, scalar, or - SDK object) is serialized if possible, otherwise wrapped under ``value``. - """ - if not arguments: - return {} - if isinstance(arguments, dict): - return arguments - if isinstance(arguments, str): - try: - parsed = json.loads(arguments) - except (json.JSONDecodeError, ValueError): - return {"raw": arguments} - return parsed if isinstance(parsed, dict) else {"value": parsed} - # Non-string, non-dict (e.g. a provider tool passing a list / scalar / SDK - # object). Prefer the object's own dict form; fall back to wrapping it. - dumped = arguments.model_dump() if hasattr(arguments, "model_dump") else None - if isinstance(dumped, dict): - return dumped - return {"value": arguments} - - -def _extract_tool_call_info(tool_call_item: Any) -> tuple[str, str, dict[str, Any]]: - """ - Extract call_id, tool_name, and tool_arguments from a tool call item. - Args: - tool_call_item: The tool call item to process - Returns: - A tuple of (call_id, tool_name, tool_arguments) - """ - # Generic handling for different tool call types - # Try 'call_id' first, then 'id', then generate placeholder - if hasattr(tool_call_item, "call_id"): - call_id = tool_call_item.call_id - elif hasattr(tool_call_item, "id"): - call_id = tool_call_item.id - else: - call_id = f"unknown_call_{id(tool_call_item)}" - - if isinstance(tool_call_item, ResponseFunctionWebSearch): - tool_name = "web_search" - tool_arguments = {"action": tool_call_item.action.model_dump(), "status": tool_call_item.status} - elif isinstance(tool_call_item, ResponseCodeInterpreterToolCall): - tool_name = "code_interpreter" - tool_arguments = {"code": tool_call_item.code, "status": tool_call_item.status} - elif isinstance(tool_call_item, ResponseFunctionToolCall): - # Handle standard function tool calls - tool_name = tool_call_item.name - tool_arguments = _safe_parse_arguments(tool_call_item.arguments) - else: - # Generic handling for any tool call type - tool_name = getattr(tool_call_item, "name", type(tool_call_item).__name__) - if hasattr(tool_call_item, "arguments"): - tool_arguments = _safe_parse_arguments(tool_call_item.arguments) - else: - tool_arguments = tool_call_item.model_dump() - - return call_id, tool_name, tool_arguments - - -def _extract_tool_response_info(tool_map: dict[str, Any], tool_output_item: Any) -> tuple[str, str, str]: - """ - Extract call_id, tool_name, and content from a tool output item. - Args: - tool_map: Dictionary mapping call_ids to tool names - tool_output_item: The tool output item to process - Returns: - A tuple of (call_id, tool_name, content) - """ - - # Handle different formats of tool_output_item - if isinstance(tool_output_item, dict): - call_id = tool_output_item.get("call_id", tool_output_item.get("id", f"unknown_call_{id(tool_output_item)}")) - content = tool_output_item.get("output", str(tool_output_item)) - else: - # Try to get call_id from attributes - if hasattr(tool_output_item, "call_id"): - call_id = tool_output_item.call_id - elif hasattr(tool_output_item, "id"): - call_id = tool_output_item.id - else: - call_id = f"unknown_call_{id(tool_output_item)}" - - # Get content - if hasattr(tool_output_item, "output"): - content = tool_output_item.output - else: - content = str(tool_output_item) - - # Get tool name from map - tool_name = tool_map.get(call_id, "unknown_tool") - - return call_id, tool_name, content - - -async def convert_openai_to_agentex_events(stream_response): - """Public OpenAI tap: parse the event stream, closing the source on exit. - - Thin wrapper over ``_convert_openai_impl`` that adds a cancellation-safe - ``finally`` so an interrupted turn tears down the source event stream instead - of leaking it. (Resume state is carried by the OpenAI Agents SDK input list, - not a session id, so there is no early-session_id capture like the CLI taps.) - """ - inner = _convert_openai_impl(stream_response) - try: - async for event in inner: - yield event - finally: - for _src in (inner, stream_response): - _aclose = getattr(_src, "aclose", None) - if _aclose is not None: - await _aclose() - - -async def _convert_openai_impl(stream_response): - """Convert OpenAI streaming events to AgentEx TaskMessageUpdate events with reasoning support. - - This is an enhanced version of the base converter that includes support for: - - Reasoning content deltas (for o1 models) - - Reasoning summary deltas (for o1 models) - - Args: - stream_response: An async iterator of OpenAI streaming events - Yields: - TaskMessageUpdate: AgentEx streaming events (StreamTaskMessageDelta, StreamTaskMessageFull, or StreamTaskMessageDone) - """ - - tool_map = {} - event_count = 0 - message_index = 0 # Track message index for proper sequencing - item_id_to_index = {} # Map item_id to message index - item_id_to_type = {} # Map item_id to content type (text, reasoning_content, reasoning_summary) - - async for event in stream_response: - event_count += 1 - - # Check for raw response events which contain the actual OpenAI streaming events - if hasattr(event, "type") and event.type == "raw_response_event": - if hasattr(event, "data"): - raw_event = event.data - - # Check for ResponseOutputItemAddedEvent which signals a new message starting - if isinstance(raw_event, ResponseOutputItemAddedEvent): - # Don't increment here - we'll increment when we see the actual text delta - # This is just a signal that a new message is starting - pass - - # Handle item completion - send done event to close the message - elif isinstance(raw_event, ResponseOutputItemDoneEvent): - item_id = raw_event.item.id - if item_id in item_id_to_index: - # Close every streamed message โ€” text AND reasoning โ€” with a - # matching Done. UnifiedEmitter.auto_send only releases a - # context on StreamTaskMessageDone; skipping it for reasoning - # left those messages hanging and their spans incomplete. The - # accumulator rebuilds ReasoningContent from the deltas, so the - # Done carries no payload. - yield StreamTaskMessageDone( - type="done", - index=item_id_to_index[item_id], - ) - - # Skip reasoning summary part added events - we handle them on delta - elif isinstance(raw_event, ResponseReasoningSummaryPartAddedEvent): - pass - - # Handle reasoning summary text delta events - elif isinstance(raw_event, ResponseReasoningSummaryTextDeltaEvent): - item_id = raw_event.item_id - summary_index = raw_event.summary_index - - # If this is a new item_id we haven't seen, create a new message - if item_id and item_id not in item_id_to_index: - message_index += 1 - item_id_to_index[item_id] = message_index - item_id_to_type[item_id] = "reasoning_summary" - - # Send a start event for this new reasoning summary message. - # The start content must be ReasoningContent (not TextContent) - # so consumers that branch on the start event's content type - # render a reasoning/thinking indicator; the final persisted - # content is rebuilt from the reasoning deltas regardless. - yield StreamTaskMessageStart( - type="start", - index=item_id_to_index[item_id], - content=ReasoningContent( - type="reasoning", - author="agent", - summary=[], - content=[], - style="active", - ), - ) - - # Use the index for this item_id - current_index = item_id_to_index.get(item_id, message_index) - - # Yield reasoning summary delta - yield StreamTaskMessageDelta( - type="delta", - index=current_index, - delta=ReasoningSummaryDelta( - type="reasoning_summary", - summary_index=summary_index, - summary_delta=raw_event.delta, - ), - ) - - # Handle reasoning summary text done events - elif isinstance(raw_event, ResponseReasoningSummaryTextDoneEvent): - # We do NOT close the streaming context here - # as there can be multiple reasoning summaries. - # The context will be closed when the entire - # output item is done (ResponseOutputItemDoneEvent) - pass - - # Handle reasoning content text delta events - elif isinstance(raw_event, ResponseReasoningTextDeltaEvent): - item_id = raw_event.item_id - content_index = raw_event.content_index - - # If this is a new item_id we haven't seen, create a new message - if item_id and item_id not in item_id_to_index: - message_index += 1 - item_id_to_index[item_id] = message_index - item_id_to_type[item_id] = "reasoning_content" - - # Send a start event for this new reasoning content message. - # The start content must be ReasoningContent (not TextContent) - # so consumers that branch on the start event's content type - # render a reasoning/thinking indicator; the final persisted - # content is rebuilt from the reasoning deltas regardless. - yield StreamTaskMessageStart( - type="start", - index=item_id_to_index[item_id], - content=ReasoningContent( - type="reasoning", - author="agent", - summary=[], - content=[], - style="active", - ), - ) - - # Use the index for this item_id - current_index = item_id_to_index.get(item_id, message_index) - - # Yield reasoning content delta - yield StreamTaskMessageDelta( - type="delta", - index=current_index, - delta=ReasoningContentDelta( - type="reasoning_content", - content_index=content_index, - content_delta=raw_event.delta, - ), - ) - - # Handle reasoning content text done events - elif isinstance(raw_event, ResponseReasoningTextDoneEvent): - # We do NOT close the streaming context here - # as there can be multiple reasoning content texts. - # The context will be closed when the entire - # output item is done (ResponseOutputItemDoneEvent) - pass - - # Check if this is a text delta event from OpenAI - elif isinstance(raw_event, ResponseTextDeltaEvent): - # Check if this event has an item_id - item_id = getattr(raw_event, "item_id", None) - - # If this is a new item_id we haven't seen, it's a new message. - # Reserve a fresh index for every text item_id (matching the - # increment-then-use convention of the reasoning/tool paths). - # Reusing the current index let a final answer collide with the - # preceding reasoning message on reasoning-model streams. - if item_id and item_id not in item_id_to_index: - message_index += 1 - item_id_to_index[item_id] = message_index - item_id_to_type[item_id] = "text" - - # Send a start event with empty content for this new text message - yield StreamTaskMessageStart( - type="start", - index=item_id_to_index[item_id], - content=TextContent( - type="text", - author="agent", - content="", # Start with empty content, deltas will fill it - ), - ) - - # Use the index for this item_id - current_index = item_id_to_index.get(item_id, message_index) - - delta_message = StreamTaskMessageDelta( - type="delta", - index=current_index, - delta=TextDelta( - type="text", - text_delta=raw_event.delta, - ), - ) - yield delta_message - - elif hasattr(event, "type") and event.type == "run_item_stream_event": - # Skip reasoning_item events - they're handled via raw_response_event above - if hasattr(event, "item") and event.item.type == "reasoning_item": - continue - - # Check for tool_call_item type (this is when a tool is being called) - elif hasattr(event, "item") and event.item.type == "tool_call_item": - # Extract tool call information using the helper method - call_id, tool_name, tool_arguments = _extract_tool_call_info(event.item.raw_item) - tool_map[call_id] = tool_name - tool_request_content = ToolRequestContent( - tool_call_id=call_id, - name=tool_name, - arguments=tool_arguments, - author="agent", - ) - message_index += 1 # Increment for new message - yield StreamTaskMessageFull( - index=message_index, - type="full", - content=tool_request_content, - ) - - # Check for tool_call_output_item type (this is when a tool returns output) - elif hasattr(event, "item") and event.item.type == "tool_call_output_item": - # Extract tool response information using the helper method - call_id, tool_name, content = _extract_tool_response_info(tool_map, event.item.raw_item) - tool_response_content = ToolResponseContent( - tool_call_id=call_id, - name=tool_name, - content=content, - author="agent", - ) - message_index += 1 # Increment for new message - yield StreamTaskMessageFull( - type="full", - index=message_index, - content=tool_response_content, - ) diff --git a/src/agentex/lib/adk/_modules/_openai_turn.py b/src/agentex/lib/adk/_modules/_openai_turn.py deleted file mode 100644 index cfb1ce22d..000000000 --- a/src/agentex/lib/adk/_modules/_openai_turn.py +++ /dev/null @@ -1,134 +0,0 @@ -"""OpenAITurn: adapt an OpenAI Agents SDK streamed run onto the harness surface. - -A ``HarnessTurn`` exposes a single canonical ``StreamTaskMessage*`` stream plus -normalized usage. ``OpenAITurn`` wraps a ``RunResultStreaming`` (from -``Runner.run_streamed``), converts its native OpenAI events into the canonical -stream via ``convert_openai_to_agentex_events``, and after exhaustion reads the -run's ``raw_responses`` to aggregate usage into a provider-independent -``TurnUsage``. - -Delivery (yield vs auto-send) and tracing are owned by ``UnifiedEmitter``; this -module is purely the provider->canonical adapter. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, AsyncIterator - -from agents.usage import Usage - -from agentex.lib.utils.logging import make_logger -from agentex.lib.core.harness.types import TurnUsage, StreamTaskMessage -from agentex.lib.adk._modules._openai_sync import ( - convert_openai_to_agentex_events, -) - -if TYPE_CHECKING: - from agents import ModelResponse, RunResultStreaming - -logger = make_logger(__name__) - - -def openai_usage_to_turn_usage(usage: Usage | None, model: str | None) -> TurnUsage: - """Map an ``agents.Usage`` to a harness-independent ``TurnUsage``. - - All field access is defensive (``getattr(..., None)``): different model - backends populate different subsets of the usage object, and real zeros are - valid values (e.g. 0 output tokens on a pure cache hit), so we never coerce - a present-but-zero value into ``None``. - """ - if usage is None: - return TurnUsage(model=model) - - input_details = getattr(usage, "input_tokens_details", None) - output_details = getattr(usage, "output_tokens_details", None) - - return TurnUsage( - model=model, - num_llm_calls=getattr(usage, "requests", None) or 0, - input_tokens=getattr(usage, "input_tokens", None), - cached_input_tokens=getattr(input_details, "cached_tokens", None), - output_tokens=getattr(usage, "output_tokens", None), - reasoning_tokens=getattr(output_details, "reasoning_tokens", None), - total_tokens=getattr(usage, "total_tokens", None), - ) - - -def _aggregate_usage(raw_responses: list[ModelResponse]) -> Usage | None: - """Sum the per-response ``Usage`` across a run's ``ModelResponse`` list. - - Returns ``None`` when no response carries usage so the caller can emit a - usage object with only the model name set. ``Usage.add`` accumulates - requests/tokens (including cached/reasoning detail fields). - """ - total: Usage | None = None - for response in raw_responses: - resp_usage = getattr(response, "usage", None) - if resp_usage is None: - continue - if total is None: - total = Usage() - total.add(resp_usage) - return total - - -class OpenAITurn: - """A single OpenAI Agents SDK turn adapted to the ``HarnessTurn`` protocol. - - Construct with exactly one of: - - ``result``: a ``RunResultStreaming`` from ``Runner.run_streamed``. Its - ``stream_events()`` is converted to the canonical stream, and after the - stream is exhausted ``raw_responses`` is read to compute usage. - - ``stream``: a pre-built async iterator of canonical ``StreamTaskMessage`` - events (bypasses ``convert_openai_to_agentex_events``). Useful for tests - and for callers that have already produced canonical events. Usage stays - at ``TurnUsage(model=...)`` because there is no run to read usage from. - - ``coalesce_tool_requests`` is accepted for API parity with other provider - turns but is a no-op for OpenAI: the OpenAI converter already emits a single - ``Full(ToolRequestContent)`` per tool call rather than streamed argument - deltas, so there is nothing to coalesce. - """ - - def __init__( - self, - result: RunResultStreaming | None = None, - model: str | None = None, - stream: AsyncIterator[StreamTaskMessage] | None = None, - coalesce_tool_requests: bool = False, # noqa: ARG002 - API parity, no-op for OpenAI - ) -> None: - if result is None and stream is None: - raise ValueError("OpenAITurn requires either `result` or `stream`") - self._result = result - self._model = model - self._stream = stream - self._usage: TurnUsage = TurnUsage(model=model) - - @property - def events(self) -> AsyncIterator[StreamTaskMessage]: - return self._iter_events() - - async def _iter_events(self) -> AsyncIterator[StreamTaskMessage]: - if self._stream is not None: - async for event in self._stream: - yield event - return - - result = self._result - assert result is not None # guaranteed by __init__ - async for event in convert_openai_to_agentex_events(result.stream_events()): - yield event - - # Stream is exhausted: the run has finished and raw_responses is now - # populated, so usage can be aggregated and normalized. - try: - raw_responses: list[Any] = list(getattr(result, "raw_responses", None) or []) - aggregated = _aggregate_usage(raw_responses) - self._usage = openai_usage_to_turn_usage(aggregated, self._model) - except Exception as exc: # pragma: no cover - defensive: never break delivery on usage - logger.warning(f"Failed to aggregate OpenAI usage: {exc}") - self._usage = TurnUsage(model=self._model) - - def usage(self) -> TurnUsage: - """Normalized turn usage. Valid only after ``events`` is exhausted.""" - return self._usage diff --git a/src/agentex/lib/adk/_modules/_pydantic_ai_sync.py b/src/agentex/lib/adk/_modules/_pydantic_ai_sync.py deleted file mode 100644 index 75bfb64db..000000000 --- a/src/agentex/lib/adk/_modules/_pydantic_ai_sync.py +++ /dev/null @@ -1,350 +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 - -Recommended: unified surface ------------------------------ -For new handlers, prefer ``UnifiedEmitter`` + ``PydanticAITurn`` over the -bare converter. The unified surface wires tracing automatically when a -``trace_id`` is provided, so tool and reasoning spans are derived from the -same event stream with no extra setup: - - from agentex.lib.core.harness import UnifiedEmitter - from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn - - emitter = UnifiedEmitter(task_id=task_id, trace_id=trace_id, parent_span_id=parent_span_id) - turn = PydanticAITurn(agent.run_stream_events(prompt), model="openai:gpt-4o") - async for event in emitter.yield_turn(turn): - yield event # forwarded over the ACP streaming response; spans derived automatically - -``convert_pydantic_ai_to_agentex_events`` remains the low-level tap for -callers that manage their own tracing or need direct access to the raw -converted stream. -""" - -from __future__ import annotations - -import json -import inspect -from typing import Any, Callable, AsyncIterator - -from pydantic_ai.run import AgentRunResultEvent -from pydantic_ai.messages import ( - TextPart, - PartEndEvent, - ThinkingPart, - ToolCallPart, - TextPartDelta, - PartDeltaEvent, - PartStartEvent, - ToolReturnPart, - FinalResultEvent, - ThinkingPartDelta, - ToolCallPartDelta, - FunctionToolCallEvent, - FunctionToolResultEvent, -) - -from agentex.lib.utils.logging import make_logger -from agentex.types.reasoning_content import ReasoningContent -from agentex.types.task_message_delta import TextDelta -from agentex.types.tool_request_delta import ToolRequestDelta -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.types.task_message_content import TextContent -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.types.reasoning_content_delta import ReasoningContentDelta - -logger = make_logger(__name__) - - -def _args_delta_to_str(args_delta: str | dict[str, Any] | None) -> str: - """Normalize a Pydantic AI ``ToolCallPartDelta.args_delta`` to a string fragment. - - Pydantic AI emits string fragments for providers that stream JSON tokens - (OpenAI, Anthropic) and dicts for providers that emit one-shot tool calls. - Agentex's ``ToolRequestDelta.arguments_delta`` is concatenated server-side - and parsed as a single JSON object on completion, so we always produce a - string. For dict deltas this is a one-shot dump; subsequent dict deltas - will not compose correctly, but in practice dict deltas arrive as a single - final fragment. - """ - if args_delta is None: - return "" - if isinstance(args_delta, str): - return args_delta - return json.dumps(args_delta) - - -def _tool_return_content(result: ToolReturnPart | Any) -> Any: - """Best-effort extraction of the user-visible content from a tool result. - - ``FunctionToolResultEvent.part`` is ``ToolReturnPart | RetryPromptPart``. - For ``ToolReturnPart`` we surface ``.content`` directly; for ``RetryPromptPart`` - (a retry signal back to the model) we surface a string description so the - UI sees the failure reason. - """ - content = getattr(result, "content", None) - if content is None: - return str(result) - if isinstance(content, (str, int, float, bool, list, dict)): - return content - if hasattr(content, "model_dump"): - try: - return content.model_dump() - except Exception: - return str(content) - return str(content) - - -async def convert_pydantic_ai_to_agentex_events( - stream_response: AsyncIterator[Any], - on_result: Callable[[AgentRunResultEvent], Any] | None = None, -) -> AsyncIterator[StreamTaskMessageStart | StreamTaskMessageDelta | StreamTaskMessageFull | StreamTaskMessageDone]: - """Public Pydantic AI tap: convert events, closing the source stream on exit. - - Thin wrapper over ``_convert_pydantic_ai_impl`` that adds a cancellation-safe - ``finally`` so an interrupted turn tears down the source stream instead of - leaking it. - """ - inner = _convert_pydantic_ai_impl(stream_response, on_result=on_result) - try: - async for event in inner: - yield event - finally: - for _src in (inner, stream_response): - _aclose = getattr(_src, "aclose", None) - if _aclose is not None: - await _aclose() - - -async def _convert_pydantic_ai_impl( - stream_response: AsyncIterator[Any], - on_result: Callable[[AgentRunResultEvent], Any] | None = None, -) -> AsyncIterator[StreamTaskMessageStart | StreamTaskMessageDelta | StreamTaskMessageFull | StreamTaskMessageDone]: - """Convert a Pydantic AI agent event stream into Agentex stream events. - - Mapping: - PartStartEvent(TextPart) -> StreamTaskMessageStart(TextContent) - PartStartEvent(ThinkingPart) -> StreamTaskMessageStart(ReasoningContent) - PartStartEvent(ToolCallPart) -> StreamTaskMessageStart(ToolRequestContent) - PartDeltaEvent(TextPartDelta) -> StreamTaskMessageDelta(TextDelta) - PartDeltaEvent(ThinkingPart..) -> StreamTaskMessageDelta(ReasoningContentDelta) - PartDeltaEvent(ToolCallPart..) -> StreamTaskMessageDelta(ToolRequestDelta) - PartEndEvent -> StreamTaskMessageDone - FunctionToolResultEvent -> StreamTaskMessageFull(ToolResponseContent) - FunctionToolCallEvent -> (ignored โ€” already covered by Start/Delta/End) - FinalResultEvent -> (ignored โ€” informational; the run-level - AgentRunResultEvent terminates the stream) - AgentRunResultEvent -> (ignored โ€” Agentex closes the per-message - stream via PartEndEvent already) - - Args: - stream_response: The async iterator yielded by Pydantic AI's - ``agent.run_stream_events(...)`` context manager (or a stream of - ``AgentStreamEvent`` items received in an ``event_stream_handler``). - on_result: Optional callback invoked with the terminal - ``AgentRunResultEvent`` when the run completes. Both sync and - async callables are accepted. No ``StreamTaskMessage*`` events are - yielded for this terminal event; the callback is the only side - effect. Useful for capturing run-level usage without altering the - streaming output. - - Yields: - Agentex ``StreamTaskMessage*`` events suitable for forwarding back over - the ACP streaming response. - """ - next_message_index = 0 - # Maps Pydantic AI's per-response part index to our absolute message index. - # Part indices restart at 0 on each new model response in a multi-step run, - # so we always overwrite the entry on PartStartEvent. - part_to_message_index: dict[int, int] = {} - # Tool-call metadata indexed by Pydantic AI part index (so deltas can - # surface the tool_call_id even when ToolCallPartDelta.tool_call_id is None). - tool_call_meta: dict[int, tuple[str, str]] = {} - - async for event in stream_response: - if isinstance(event, PartStartEvent): - message_index = next_message_index - next_message_index += 1 - part_to_message_index[event.index] = message_index - - if isinstance(event.part, TextPart): - yield StreamTaskMessageStart( - type="start", - index=message_index, - content=TextContent( - type="text", - author="agent", - content="", - ), - ) - if event.part.content: - yield StreamTaskMessageDelta( - type="delta", - index=message_index, - delta=TextDelta(type="text", text_delta=event.part.content), - ) - elif isinstance(event.part, ThinkingPart): - yield StreamTaskMessageStart( - type="start", - index=message_index, - content=ReasoningContent( - type="reasoning", - author="agent", - summary=[], - content=[], - style="active", - ), - ) - if event.part.content: - yield StreamTaskMessageDelta( - type="delta", - index=message_index, - delta=ReasoningContentDelta( - type="reasoning_content", - content_index=0, - content_delta=event.part.content, - ), - ) - elif isinstance(event.part, ToolCallPart): - tool_call_meta[event.index] = (event.part.tool_call_id, event.part.tool_name) - # Pydantic AI may already have a fully-formed args dict at start - # when the provider returns the tool call in one shot; surface it - # directly so clients see the complete arguments without waiting - # for deltas. - initial_args: dict[str, Any] = {} - if isinstance(event.part.args, dict): - # dict(...) materializes a fresh dict[str, Any]; pydantic-ai's - # ToolCallPart.args includes TypedDict-style variants that - # pyright doesn't narrow to plain dict[str, Any] via isinstance. - initial_args = dict(event.part.args) - yield StreamTaskMessageStart( - type="start", - index=message_index, - content=ToolRequestContent( - type="tool_request", - author="agent", - tool_call_id=event.part.tool_call_id, - name=event.part.tool_name, - arguments=initial_args, - ), - ) - if isinstance(event.part.args, str) and event.part.args: - yield StreamTaskMessageDelta( - type="delta", - index=message_index, - delta=ToolRequestDelta( - type="tool_request", - tool_call_id=event.part.tool_call_id, - name=event.part.tool_name, - arguments_delta=event.part.args, - ), - ) - else: - logger.debug("Unhandled PartStartEvent part type: %r", type(event.part).__name__) - - elif isinstance(event, PartDeltaEvent): - message_index = part_to_message_index.get(event.index) - if message_index is None: - logger.debug("PartDeltaEvent for unknown part index %s; skipping", event.index) - continue - - if isinstance(event.delta, TextPartDelta): - yield StreamTaskMessageDelta( - type="delta", - index=message_index, - delta=TextDelta(type="text", text_delta=event.delta.content_delta), - ) - elif isinstance(event.delta, ThinkingPartDelta): - if event.delta.content_delta: - yield StreamTaskMessageDelta( - type="delta", - index=message_index, - delta=ReasoningContentDelta( - type="reasoning_content", - content_index=0, - content_delta=event.delta.content_delta, - ), - ) - elif isinstance(event.delta, ToolCallPartDelta): - meta = tool_call_meta.get(event.index) - if meta is None: - # First time we've seen this part; the provider didn't emit - # a PartStartEvent first. Synthesize one from the delta if - # we have enough information. - tool_call_id = event.delta.tool_call_id or "" - tool_name = event.delta.tool_name_delta or "" - tool_call_meta[event.index] = (tool_call_id, tool_name) - else: - tool_call_id, tool_name = meta - yield StreamTaskMessageDelta( - type="delta", - index=message_index, - delta=ToolRequestDelta( - type="tool_request", - tool_call_id=tool_call_id, - name=tool_name, - arguments_delta=_args_delta_to_str(event.delta.args_delta), - ), - ) - else: - logger.debug("Unhandled PartDeltaEvent delta type: %r", type(event.delta).__name__) - - elif isinstance(event, PartEndEvent): - message_index = part_to_message_index.get(event.index) - if message_index is None: - continue - yield StreamTaskMessageDone(type="done", index=message_index) - - elif isinstance(event, FunctionToolResultEvent): - result = event.part - tool_call_id = result.tool_call_id - tool_name = getattr(result, "tool_name", "") or "" - message_index = next_message_index - next_message_index += 1 - content_payload = _tool_return_content(result) - yield StreamTaskMessageFull( - type="full", - index=message_index, - content=ToolResponseContent( - type="tool_response", - author="agent", - tool_call_id=tool_call_id, - name=tool_name, - content=content_payload, - ), - ) - - elif isinstance(event, (FunctionToolCallEvent, FinalResultEvent, AgentRunResultEvent)): - # Already covered by PartStart/PartDelta/PartEnd events above, or - # informational only (FinalResultEvent / AgentRunResultEvent signal - # run-level state, not new content to surface). - if isinstance(event, AgentRunResultEvent) and on_result is not None: - ret = on_result(event) - if inspect.iscoroutine(ret): - await ret - continue - - else: - logger.debug("Unhandled Pydantic AI event type: %r", type(event).__name__) diff --git a/src/agentex/lib/adk/_modules/_pydantic_ai_turn.py b/src/agentex/lib/adk/_modules/_pydantic_ai_turn.py deleted file mode 100644 index 4e9340d7a..000000000 --- a/src/agentex/lib/adk/_modules/_pydantic_ai_turn.py +++ /dev/null @@ -1,173 +0,0 @@ -"""PydanticAITurn: a HarnessTurn wrapping a pydantic-ai event stream. - -Adapts a pydantic-ai ``AgentStreamEvent`` stream into the canonical -``StreamTaskMessage*`` stream while capturing run-level usage from the -terminal ``AgentRunResultEvent``. - -Typical usage:: - - async with agent.run_stream_events(user_msg) as stream: - turn = PydanticAITurn(stream, model="openai:gpt-4o") - async for event in turn.events: - yield event - span.set_attributes(turn.usage().model_dump()) -""" - -from __future__ import annotations - -from typing import Any, AsyncIterator - -from pydantic_ai.run import AgentRunResultEvent - -from agentex.lib.core.harness.types import TurnUsage -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.lib.adk._modules._pydantic_ai_sync import convert_pydantic_ai_to_agentex_events - -StreamTaskMessage = StreamTaskMessageStart | StreamTaskMessageDelta | StreamTaskMessageFull | StreamTaskMessageDone - - -def pydantic_ai_usage_to_turn_usage(usage: Any, model: str | None) -> TurnUsage: - """Map a pydantic-ai ``RunUsage`` onto ``TurnUsage``. - - Uses defensive ``getattr(..., None)`` so a future field rename in - pydantic-ai degrades to ``None`` rather than raising ``AttributeError``. - - RunUsage fields (verified against pydantic-ai in this repo): - input_tokens, cache_write_tokens, cache_read_tokens, output_tokens, - input_audio_tokens, cache_audio_read_tokens, output_audio_tokens, - details, requests, tool_calls. - ``total_tokens`` is a computed property. - - Mapping: - requests -> num_llm_calls - input_tokens -> input_tokens - output_tokens -> output_tokens - cache_read_tokens -> cached_input_tokens - total_tokens -> total_tokens - - getattr results pass straight through: a MISSING attribute degrades to - None (defensive), while a real 0 stays 0 (a cache-hit with 0 output - tokens is a genuine zero, not "unknown") and a real N stays N. - """ - raw_input = getattr(usage, "input_tokens", None) - raw_output = getattr(usage, "output_tokens", None) - raw_cache_read = getattr(usage, "cache_read_tokens", None) - raw_total = getattr(usage, "total_tokens", None) - raw_requests = getattr(usage, "requests", None) - - return TurnUsage( - model=model, - input_tokens=raw_input, - output_tokens=raw_output, - cached_input_tokens=raw_cache_read, - total_tokens=raw_total, - num_llm_calls=raw_requests if raw_requests is not None else 0, - ) - - -class PydanticAITurn: - """A single harness turn backed by a pydantic-ai event stream. - - Satisfies the ``HarnessTurn`` protocol: ``events`` async-generates the - canonical ``StreamTaskMessage*`` stream; ``usage()`` returns a normalized - ``TurnUsage`` (valid only after ``events`` is exhausted). - - ``events`` is identical to the bare ``convert_pydantic_ai_to_agentex_events`` - output (tool calls stream as ``Start + ToolRequestDelta + Done``, preserving - argument-token streaming on the sync/yield channel). The foundation - ``auto_send`` delivers the streamed tool-request shape natively, so no - coalescing is needed on either channel. - """ - - def __init__( - self, - stream: AsyncIterator[Any], - model: str | None = None, - ) -> None: - self._stream = stream - self._model = model - self._usage = TurnUsage(model=model) - - @property - def events(self) -> AsyncIterator[StreamTaskMessage]: - return self._generate_events() - - async def _generate_events(self) -> AsyncIterator[StreamTaskMessage]: - def _capture(result_event: AgentRunResultEvent) -> None: - run_result = getattr(result_event, "result", None) - if run_result is None: - return - usage_attr = getattr(run_result, "usage", None) - if usage_attr is None: - return - # In newer pydantic-ai, .usage is a DeprecatedCallableRunUsage โ€” - # it's both a property value and callable (emitting a deprecation - # warning when called). Access it as a plain attribute to avoid the - # warning; it already IS the RunUsage instance. - usage_obj = usage_attr - self._usage = pydantic_ai_usage_to_turn_usage(usage_obj, self._model) - - raw_stream = convert_pydantic_ai_to_agentex_events( - self._stream, - on_result=_capture, - ) - async for ev in raw_stream: - yield ev - - def usage(self) -> TurnUsage: - """Return the normalized usage for this turn. - - Valid only after ``events`` is exhausted (single-pass contract). - Before exhaustion the model field is set but token fields are None. - """ - return self._usage - - -async def stream_pydantic_ai_events( - stream, - task_id: str, -) -> str: - """Stream Pydantic AI events to Agentex via Redis. - - Consumes a Pydantic AI ``agent.run_stream_events(...)`` async iterator and - pushes Agentex streaming updates to Redis via the ``adk.streaming`` - contexts. For use with async ACP agents that stream via Redis rather than - HTTP yields. - - Text and thinking tokens stream as deltas inside coalesced streaming - contexts. Tool requests and tool results are posted as open+close pairs - on a streaming context (the unified surface persists ``initial_content`` - when a context is closed without deltas). This matches the ``auto_send`` - convention used by all other async/Temporal harnesses. - - Tracing is derived automatically from the event stream by the emitter when - a ``trace_id`` is provided to the ``UnifiedEmitter``. - - Args: - stream: Async iterator yielded by ``agent.run_stream_events(...)``. - task_id: The Agentex task ID to stream messages to. - - Returns: - The accumulated text content of the **last** text part in the run. - Multi-step runs (where the model emits text, then a tool call, then - more text) return only the final text segment, matching the - ``stream_langgraph_events`` convention. - """ - from agentex.lib.core.harness.emitter import UnifiedEmitter - - turn = PydanticAITurn( - stream, - model=None, - ) - emitter = UnifiedEmitter( - task_id=task_id, - trace_id=None, - parent_span_id=None, - ) - result = await emitter.auto_send_turn(turn) - return result.final_text diff --git a/src/agentex/lib/adk/_modules/acp.py b/src/agentex/lib/adk/_modules/acp.py deleted file mode 100644 index 8a8e89236..000000000 --- a/src/agentex/lib/adk/_modules/acp.py +++ /dev/null @@ -1,294 +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: Optional human-readable name for the task. task/create is - get-or-create by name: omit it (or make it unique, e.g. append a - UUID) for a fresh task on each call; passing a name that already - exists returns that task with its prior history instead of - creating a new one. Keep it globally unique when set. - agent_id: The ID of the agent to create the task for. - agent_name: The name of the agent to create the task for. - params: The parameters for the task. - start_to_close_timeout: The start to close timeout for the task. - heartbeat_timeout: The heartbeat timeout for the task. - retry_policy: The retry policy for the task. - request: Additional request context including headers to forward to the agent. - - Returns: - The task entry. - """ - if in_temporal_workflow(): - return await ActivityHelpers.execute_activity( - activity_name=ACPActivityName.TASK_CREATE, - request=TaskCreateParams( - name=name, - agent_id=agent_id, - agent_name=agent_name, - params=params, - trace_id=trace_id, - parent_span_id=parent_span_id, - request=request, - ), - response_type=Task, - start_to_close_timeout=start_to_close_timeout, - retry_policy=retry_policy, - heartbeat_timeout=heartbeat_timeout, - ) - else: - return await self._acp_service.task_create( - name=name, - agent_id=agent_id, - agent_name=agent_name, - params=params, - trace_id=trace_id, - parent_span_id=parent_span_id, - request=request, - ) - - async def send_event( - self, - task_id: str, - content: TaskMessageContent, - agent_id: str | None = None, - agent_name: str | None = None, - trace_id: str | None = None, - parent_span_id: str | None = None, - start_to_close_timeout: timedelta = timedelta(seconds=5), - heartbeat_timeout: timedelta = timedelta(seconds=5), - retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, - request: dict[str, Any] | None = None, - ) -> Event: - """ - Send an event to a task. - - Args: - task_id: The ID of the task to send the event to. - content: The content to send to the event. - agent_id: The ID of the agent to send the event to. - agent_name: The name of the agent to send the event to. - trace_id: The trace ID for the event. - parent_span_id: The parent span ID for the event. - start_to_close_timeout: The start to close timeout for the event. - heartbeat_timeout: The heartbeat timeout for the event. - retry_policy: The retry policy for the event. - request: Additional request context including headers to forward to the agent. - - Returns: - The event entry. - """ - if in_temporal_workflow(): - return await ActivityHelpers.execute_activity( - activity_name=ACPActivityName.EVENT_SEND, - request=EventSendParams( - agent_id=agent_id, - agent_name=agent_name, - task_id=task_id, - content=content, - trace_id=trace_id, - parent_span_id=parent_span_id, - request=request, - ), - response_type=None, - start_to_close_timeout=start_to_close_timeout, - retry_policy=retry_policy, - heartbeat_timeout=heartbeat_timeout, - ) - else: - return await self._acp_service.event_send( - agent_id=agent_id, - agent_name=agent_name, - task_id=task_id, - content=content, - trace_id=trace_id, - parent_span_id=parent_span_id, - request=request, - ) - - async def send_message( - self, - content: TaskMessageContent, - task_id: str | None = None, - agent_id: str | None = None, - agent_name: str | None = None, - trace_id: str | None = None, - parent_span_id: str | None = None, - start_to_close_timeout: timedelta = timedelta(seconds=5), - heartbeat_timeout: timedelta = timedelta(seconds=5), - retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, - request: dict[str, Any] | None = None, - ) -> List[TaskMessage]: - """ - Send a message to a task. - - Args: - content: The task message content to send to the task. - task_id: The ID of the task to send the message to. - agent_id: The ID of the agent to send the message to. - agent_name: The name of the agent to send the message to. - trace_id: The trace ID for the message. - parent_span_id: The parent span ID for the message. - start_to_close_timeout: The start to close timeout for the message. - heartbeat_timeout: The heartbeat timeout for the message. - retry_policy: The retry policy for the message. - request: Additional request context including headers to forward to the agent. - - Returns: - The message entry. - """ - if in_temporal_workflow(): - return await ActivityHelpers.execute_activity( - activity_name=ACPActivityName.MESSAGE_SEND, - request=MessageSendParams( - agent_id=agent_id, - agent_name=agent_name, - task_id=task_id, - content=content, - trace_id=trace_id, - parent_span_id=parent_span_id, - request=request, - ), - response_type=TaskMessage, - start_to_close_timeout=start_to_close_timeout, - retry_policy=retry_policy, - heartbeat_timeout=heartbeat_timeout, - ) - else: - return await self._acp_service.message_send( - agent_id=agent_id, - agent_name=agent_name, - task_id=task_id, - content=content, - trace_id=trace_id, - parent_span_id=parent_span_id, - request=request, - ) - - async def cancel_task( - self, - task_id: str | None = None, - task_name: str | None = None, - agent_id: str | None = None, - agent_name: str | None = None, - trace_id: str | None = None, - parent_span_id: str | None = None, - start_to_close_timeout: timedelta = timedelta(seconds=5), - heartbeat_timeout: timedelta = timedelta(seconds=5), - retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, - request: dict[str, Any] | None = None, - ) -> Task: - """ - Cancel a task by sending cancel request to the agent that owns the task. - - Args: - task_id: ID of the task to cancel. - task_name: Name of the task to cancel. - agent_id: ID of the agent that owns the task. - agent_name: Name of the agent that owns the task. - trace_id: The trace ID for the task. - parent_span_id: The parent span ID for the task. - start_to_close_timeout: The start to close timeout for the task. - heartbeat_timeout: The heartbeat timeout for the task. - retry_policy: The retry policy for the task. - request: Additional request context including headers to forward to the agent. - - Returns: - The task entry. - - Raises: - ValueError: If neither agent_name nor agent_id is provided, - or if neither task_name nor task_id is provided - """ - if in_temporal_workflow(): - return await ActivityHelpers.execute_activity( - activity_name=ACPActivityName.TASK_CANCEL, - request=TaskCancelParams( - task_id=task_id, - task_name=task_name, - agent_id=agent_id, - agent_name=agent_name, - trace_id=trace_id, - parent_span_id=parent_span_id, - request=request, - ), - response_type=None, - start_to_close_timeout=start_to_close_timeout, - retry_policy=retry_policy, - heartbeat_timeout=heartbeat_timeout, - ) - else: - return await self._acp_service.task_cancel( - task_id=task_id, - task_name=task_name, - agent_id=agent_id, - agent_name=agent_name, - trace_id=trace_id, - parent_span_id=parent_span_id, - request=request, - ) diff --git a/src/agentex/lib/adk/_modules/agent_task_tracker.py b/src/agentex/lib/adk/_modules/agent_task_tracker.py 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 d842f23ee..000000000 --- a/src/agentex/lib/adk/_modules/tasks.py +++ /dev/null @@ -1,478 +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 interrupt( - self, - *, - task_id: str, - reason: str | None = None, - trace_id: str | None = None, - parent_span_id: str | None = None, - start_to_close_timeout: timedelta = timedelta(seconds=5), - heartbeat_timeout: timedelta = timedelta(seconds=5), - retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, - ) -> Task: - """ - Mark a running task as interrupted (non-terminal). - - Interrupt is cooperative: an agent calls this from its interrupt handler, - after it has actually stopped its in-flight turn, to record INTERRUPTED. - The task stays continuable; the control plane resumes it to RUNNING on the - next turn. - Args: - task_id: The ID of the task to interrupt. - reason: Optional reason for the interrupt. - Returns: - The updated task entry. - """ - params = TaskStatusTransitionParams( - task_id=task_id, - reason=reason, - trace_id=trace_id, - parent_span_id=parent_span_id, - ) - if in_temporal_workflow(): - return await ActivityHelpers.execute_activity( - activity_name=TasksActivityName.INTERRUPT_TASK, - request=params, - response_type=Task, - start_to_close_timeout=start_to_close_timeout, - retry_policy=retry_policy, - heartbeat_timeout=heartbeat_timeout, - ) - else: - return await self._tasks_service.interrupt_task( - task_id=task_id, - reason=reason, - trace_id=trace_id, - parent_span_id=parent_span_id, - ) - - async def complete( - self, - *, - task_id: str, - reason: str | None = None, - trace_id: str | None = None, - parent_span_id: str | None = None, - start_to_close_timeout: timedelta = timedelta(seconds=5), - heartbeat_timeout: timedelta = timedelta(seconds=5), - retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, - ) -> Task: - """ - Mark a running task as completed. - Args: - task_id: The ID of the task to complete. - reason: Optional reason for completion. - Returns: - The updated task entry. - """ - params = TaskStatusTransitionParams( - task_id=task_id, - reason=reason, - trace_id=trace_id, - parent_span_id=parent_span_id, - ) - if in_temporal_workflow(): - return await ActivityHelpers.execute_activity( - activity_name=TasksActivityName.COMPLETE_TASK, - request=params, - response_type=Task, - start_to_close_timeout=start_to_close_timeout, - retry_policy=retry_policy, - heartbeat_timeout=heartbeat_timeout, - ) - else: - return await self._tasks_service.complete_task( - task_id=task_id, - reason=reason, - trace_id=trace_id, - parent_span_id=parent_span_id, - ) - - async def fail( - self, - *, - task_id: str, - reason: str | None = None, - trace_id: str | None = None, - parent_span_id: str | None = None, - start_to_close_timeout: timedelta = timedelta(seconds=5), - heartbeat_timeout: timedelta = timedelta(seconds=5), - retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, - ) -> Task: - """ - Mark a running task as failed. - Args: - task_id: The ID of the task to fail. - reason: Optional reason for failure. - Returns: - The updated task entry. - """ - params = TaskStatusTransitionParams( - task_id=task_id, - reason=reason, - trace_id=trace_id, - parent_span_id=parent_span_id, - ) - if in_temporal_workflow(): - return await ActivityHelpers.execute_activity( - activity_name=TasksActivityName.FAIL_TASK, - request=params, - response_type=Task, - start_to_close_timeout=start_to_close_timeout, - retry_policy=retry_policy, - heartbeat_timeout=heartbeat_timeout, - ) - else: - return await self._tasks_service.fail_task( - task_id=task_id, - reason=reason, - trace_id=trace_id, - parent_span_id=parent_span_id, - ) - - async def terminate( - self, - *, - task_id: str, - reason: str | None = None, - trace_id: str | None = None, - parent_span_id: str | None = None, - start_to_close_timeout: timedelta = timedelta(seconds=5), - heartbeat_timeout: timedelta = timedelta(seconds=5), - retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, - ) -> Task: - """ - Mark a running task as terminated. - Args: - task_id: The ID of the task to terminate. - reason: Optional reason for termination. - Returns: - The updated task entry. - """ - params = TaskStatusTransitionParams( - task_id=task_id, - reason=reason, - trace_id=trace_id, - parent_span_id=parent_span_id, - ) - if in_temporal_workflow(): - return await ActivityHelpers.execute_activity( - activity_name=TasksActivityName.TERMINATE_TASK, - request=params, - response_type=Task, - start_to_close_timeout=start_to_close_timeout, - retry_policy=retry_policy, - heartbeat_timeout=heartbeat_timeout, - ) - else: - return await self._tasks_service.terminate_task( - task_id=task_id, - reason=reason, - trace_id=trace_id, - parent_span_id=parent_span_id, - ) - - async def timeout( - self, - *, - task_id: str, - reason: str | None = None, - trace_id: str | None = None, - parent_span_id: str | None = None, - start_to_close_timeout: timedelta = timedelta(seconds=5), - heartbeat_timeout: timedelta = timedelta(seconds=5), - retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, - ) -> Task: - """ - Mark a running task as timed out. - Args: - task_id: The ID of the task to time out. - reason: Optional reason for timeout. - Returns: - The updated task entry. - """ - params = TaskStatusTransitionParams( - task_id=task_id, - reason=reason, - trace_id=trace_id, - parent_span_id=parent_span_id, - ) - if in_temporal_workflow(): - return await ActivityHelpers.execute_activity( - activity_name=TasksActivityName.TIMEOUT_TASK, - request=params, - response_type=Task, - start_to_close_timeout=start_to_close_timeout, - retry_policy=retry_policy, - heartbeat_timeout=heartbeat_timeout, - ) - else: - return await self._tasks_service.timeout_task( - task_id=task_id, - reason=reason, - trace_id=trace_id, - parent_span_id=parent_span_id, - ) - - async def update( - self, - *, - task_id: str | None = None, - task_name: str | None = None, - task_metadata: dict[str, object] | None = None, - trace_id: str | None = None, - parent_span_id: str | None = None, - start_to_close_timeout: timedelta = timedelta(seconds=5), - heartbeat_timeout: timedelta = timedelta(seconds=5), - retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, - ) -> Task: - """ - Update mutable fields for a task by ID or name. - Args: - task_id: The ID of the task to update. - task_name: The name of the task to update. - task_metadata: Metadata to update on the task. - Returns: - The updated task entry. - """ - params = UpdateTaskParams( - task_id=task_id, - task_name=task_name, - task_metadata=task_metadata, - trace_id=trace_id, - parent_span_id=parent_span_id, - ) - if in_temporal_workflow(): - return await ActivityHelpers.execute_activity( - activity_name=TasksActivityName.UPDATE_TASK, - request=params, - response_type=Task, - start_to_close_timeout=start_to_close_timeout, - retry_policy=retry_policy, - heartbeat_timeout=heartbeat_timeout, - ) - else: - return await self._tasks_service.update_task( - task_id=task_id, - task_name=task_name, - task_metadata=task_metadata, - trace_id=trace_id, - parent_span_id=parent_span_id, - ) - - async def query_workflow( - self, - *, - task_id: str, - query_name: str, - trace_id: str | None = None, - parent_span_id: str | None = None, - start_to_close_timeout: timedelta = timedelta(seconds=5), - heartbeat_timeout: timedelta = timedelta(seconds=5), - retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, - ) -> dict[str, object]: - """ - Query a Temporal workflow associated with a task for its current state. - Args: - task_id: The ID of the task whose workflow to query. - query_name: The name of the query to execute. - Returns: - The query result. - """ - params = QueryWorkflowParams( - task_id=task_id, - query_name=query_name, - trace_id=trace_id, - parent_span_id=parent_span_id, - ) - if in_temporal_workflow(): - return await ActivityHelpers.execute_activity( - activity_name=TasksActivityName.QUERY_WORKFLOW, - request=params, - response_type=dict, - start_to_close_timeout=start_to_close_timeout, - retry_policy=retry_policy, - heartbeat_timeout=heartbeat_timeout, - ) - else: - return await self._tasks_service.query_workflow( - task_id=task_id, - query_name=query_name, - trace_id=trace_id, - parent_span_id=parent_span_id, - ) diff --git a/src/agentex/lib/adk/_modules/tracing.py b/src/agentex/lib/adk/_modules/tracing.py deleted file mode 100644 index 7d49bb91c..000000000 --- a/src/agentex/lib/adk/_modules/tracing.py +++ /dev/null @@ -1,408 +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 import workflow -from temporalio.common import RetryPolicy -from temporalio.exceptions import ActivityError, TimeoutError as TemporalTimeoutError, is_cancelled_exception - -from agentex import AsyncAgentex # noqa: F401 -from agentex.lib.adk.utils._modules.client import create_async_agentex_client -from agentex.lib.core.services.adk.tracing import TracingService -from agentex.lib.core.temporal.activities.activity_helpers import ActivityHelpers -from agentex.lib.core.temporal.activities.adk.tracing_activities import ( - EndSpanParams, - StartSpanParams, - TracingActivityName, -) -from agentex.lib.core.tracing.tracer import AsyncTracer -from agentex.lib.core.harness.types import TurnUsage -from agentex.types.span import Span -from agentex.lib.utils.logging import make_logger -from agentex.lib.utils.model_utils import BaseModel -from agentex.lib.utils.temporal import in_temporal_workflow - -logger = make_logger(__name__) - -DEFAULT_RETRY_POLICY = RetryPolicy(maximum_attempts=1) -TEMPORAL_SPAN_ACTIVITY_DROPPED_METRIC = "agentex.tracing.temporal_span_activity.dropped" - -# Token key spellings the backend accepts when billing usage from spans. -RECOGNIZED_USAGE_KEYS = frozenset( - { - "input_tokens", - "prompt_tokens", - "output_tokens", - "completion_tokens", - "cached_input_tokens", - "cached_tokens", - "reasoning_tokens", - "total_tokens", - "cost_usd", - } -) - - -def _record_temporal_span_activity_dropped(event_type: str) -> None: - try: - workflow.metric_meter().create_counter( - TEMPORAL_SPAN_ACTIVITY_DROPPED_METRIC, - description="Temporal tracing span activities dropped after fail-open", - unit="1", - ).add(1, {"event_type": event_type}) - except Exception: - pass - - -class TurnSpan: - """Handle for a turn-level (rollup) span, yielded by ``TracingModule.turn_span``. - - Encapsulates the billing contract so agents cannot double-count usage: - the turn's aggregate usage goes to ``span.data["usage"]`` (+ - ``span.data["cost_usd"]``) via :meth:`record_usage`. The backend keeps the - aggregate and de-dups any per-call ``output["usage"]`` children against it. - Never hand-write usage into ``output`` on a rollup span โ€” that is the - double-count bug this helper exists to prevent. - - All methods no-op when tracing is disabled (``span`` is None), so agent - code needs no ``if span:`` guards. - """ - - def __init__(self, span: Span | None): - self.span = span - - def record_usage( - self, - usage: TurnUsage | dict[str, Any] | None = None, - cost_usd: float | None = None, - ) -> None: - """Record the turn's aggregate usage on the span's ``data``. - - Pass the harness ``TurnUsage`` (e.g. ``LangGraphTurn.usage()`` or - ``run_turn(...).usage``) โ€” its ``cost_usd`` is stamped automatically โ€” - or a plain dict with backend-recognized token spellings - (``prompt_tokens``/``completion_tokens`` also work). An explicit - ``cost_usd`` argument overrides any cost carried by ``usage``. The - usage must be this turn's own tokens, not a session-cumulative total. - """ - if self.span is None: - return - - blob: dict[str, Any] - if isinstance(usage, TurnUsage): - blob = usage.model_dump(exclude_none=True) - # cost lives beside the blob as data["cost_usd"], not inside it - blob_cost = blob.pop("cost_usd", None) - if cost_usd is None: - cost_usd = blob_cost - elif usage is not None: - blob = dict(usage) - if not any(key in RECOGNIZED_USAGE_KEYS for key in blob): - logger.warning( - "TurnSpan.record_usage: usage has no recognized token keys and will " - f"not be billed. Got keys {sorted(blob)}; expected any of " - f"{sorted(RECOGNIZED_USAGE_KEYS)}." - ) - else: - blob = {} - - if self.span.data is not None and not isinstance(self.span.data, dict): - logger.warning( - f"TurnSpan.record_usage: span.data is {type(self.span.data).__name__} " - "(expected dict or None); existing data will be replaced." - ) - data = self.span.data if isinstance(self.span.data, dict) else {} - if blob: - data["usage"] = blob - if cost_usd is not None: - data["cost_usd"] = cost_usd - self.span.data = data - - @property - def output(self) -> Any: - return self.span.output if self.span is not None else None - - @output.setter - def output(self, value: Any) -> None: - if self.span is not None: - self.span.output = value - - -class TracingModule: - """ - Module for managing tracing and span operations in Agentex. - Provides high-level async methods for starting, ending, and managing spans for distributed tracing. - """ - - def __init__(self, tracing_service: TracingService | None = None): - """ - Initialize the tracing interface. - - Args: - tracing_service (Optional[TracingService]): Optional pre-configured tracing service. - If None, will be lazily created on first use so the httpx client is - bound to the correct running event loop. - """ - self._tracing_service_explicit = tracing_service - self._tracing_service_lazy: TracingService | None = None - self._bound_loop_id: int | None = None - - @property - def _tracing_service(self) -> TracingService: - if self._tracing_service_explicit is not None: - return self._tracing_service_explicit - - import asyncio - - # Determine the current event loop (if any). - try: - loop = asyncio.get_running_loop() - loop_id = id(loop) - except RuntimeError: - loop_id = None - - # Re-create the underlying httpx client when the event loop changes - # (e.g. between HTTP requests in a sync ASGI server) to avoid - # "Event loop is closed" / "bound to a different event loop" errors. - if self._tracing_service_lazy is None or (loop_id is not None and loop_id != self._bound_loop_id): - import httpx - - # Keepalive ON: connections are reused within a single event - # loop, eliminating the TLS-handshake-per-span penalty under - # load. Cross-loop safety is preserved by rebuilding the - # client whenever loop_id changes (the conditional above). - agentex_client = create_async_agentex_client( - http_client=httpx.AsyncClient( - limits=httpx.Limits(max_keepalive_connections=20), - ), - ) - tracer = AsyncTracer(agentex_client) - self._tracing_service_lazy = TracingService(tracer=tracer) - self._bound_loop_id = loop_id - - return self._tracing_service_lazy - - @asynccontextmanager - async def span( - self, - trace_id: str, - name: str, - input: list[Any] | dict[str, Any] | BaseModel | None = None, - data: list[Any] | dict[str, Any] | BaseModel | None = None, - parent_id: str | None = None, - task_id: str | None = None, - start_to_close_timeout: timedelta = timedelta(seconds=5), - heartbeat_timeout: timedelta = timedelta(seconds=5), - retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, - ) -> AsyncGenerator[Span | None, None]: - """ - Async context manager for creating and automatically closing a span. - Yields the started span object. The span is automatically ended when the context exits. - - If trace_id is falsy, acts as a no-op context manager. - - Args: - trace_id (str): The trace ID for the span. - name (str): The name of the span. - input (Union[List, Dict, BaseModel]): The input for the span. - parent_id (Optional[str]): The parent span ID for the span. - data (Optional[Union[List, Dict, BaseModel]]): The data for the span. - task_id (Optional[str]): The task ID this span belongs to. - start_to_close_timeout (timedelta): The start to close timeout for the span. - heartbeat_timeout (timedelta): The heartbeat timeout for the span. - retry_policy (RetryPolicy): The retry policy for the span. - - Returns: - AsyncGenerator[Optional[Span], None]: An async generator that yields the started span object. - """ - if not trace_id: - yield None - return - - span: Span | None = await self.start_span( - trace_id=trace_id, - name=name, - input=input, - parent_id=parent_id, - data=data, - task_id=task_id, - start_to_close_timeout=start_to_close_timeout, - heartbeat_timeout=heartbeat_timeout, - retry_policy=retry_policy, - ) - try: - yield span - finally: - if span: - await self.end_span( - trace_id=trace_id, - span=span, - start_to_close_timeout=start_to_close_timeout, - heartbeat_timeout=heartbeat_timeout, - retry_policy=retry_policy, - ) - - @asynccontextmanager - async def turn_span( - self, - trace_id: str, - name: str, - input: list[Any] | dict[str, Any] | BaseModel | None = None, - data: list[Any] | dict[str, Any] | BaseModel | None = None, - parent_id: str | None = None, - task_id: str | None = None, - start_to_close_timeout: timedelta = timedelta(seconds=5), - heartbeat_timeout: timedelta = timedelta(seconds=5), - retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, - ) -> AsyncGenerator[TurnSpan, None]: - """Span for one agent turn, with usage recorded as the billable aggregate. - - Same lifecycle as :meth:`span`, but yields a :class:`TurnSpan` whose - ``record_usage(usage=..., cost_usd=...)`` writes the turn's rollup - usage to ``span.data`` โ€” the shape the backend bills once per turn. - Per-call child spans (LLM adapters) may still carry - ``output["usage"]``; the backend de-dups them against this aggregate. - - Example (with a harness turn, e.g. ``LangGraphTurn`` / ``run_turn``):: - - async with adk.tracing.turn_span(trace_id=task.id, name="turn", input={...}, task_id=task.id) as turn: - result = await run_turn(...) - turn.output = {"response": result.final_output} - turn.record_usage(result.usage) # TurnUsage, cost_usd included - """ - async with self.span( - trace_id=trace_id, - name=name, - input=input, - data=data, - parent_id=parent_id, - task_id=task_id, - start_to_close_timeout=start_to_close_timeout, - heartbeat_timeout=heartbeat_timeout, - retry_policy=retry_policy, - ) as span: - yield TurnSpan(span) - - async def start_span( - self, - trace_id: str, - name: str, - input: list[Any] | dict[str, Any] | BaseModel | None = None, - parent_id: str | None = None, - data: list[Any] | dict[str, Any] | BaseModel | None = None, - task_id: str | None = None, - start_to_close_timeout: timedelta = timedelta(seconds=5), - heartbeat_timeout: timedelta = timedelta(seconds=1), - retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, - ) -> Span | None: - """ - Start a new span in the trace. - - Args: - trace_id (str): The trace ID for the span. - name (str): The name of the span. - input (Union[List, Dict, BaseModel]): The input for the span. - parent_id (Optional[str]): The parent span ID for the span. - data (Optional[Union[List, Dict, BaseModel]]): The data for the span. - task_id (Optional[str]): The task ID this span belongs to. - start_to_close_timeout (timedelta): The start to close timeout for the span. - heartbeat_timeout (timedelta): The heartbeat timeout for the span. - retry_policy (RetryPolicy): The retry policy for the span. - - Returns: - Span: The started span object. - """ - params = StartSpanParams( - trace_id=trace_id, - parent_id=parent_id, - name=name, - input=input, - data=data, - task_id=task_id, - ) - if in_temporal_workflow(): - try: - return await ActivityHelpers.execute_activity( - activity_name=TracingActivityName.START_SPAN, - request=params, - response_type=Span, - start_to_close_timeout=start_to_close_timeout, - retry_policy=retry_policy, - heartbeat_timeout=heartbeat_timeout, - ) - except (ActivityError, TemporalTimeoutError) as err: - if is_cancelled_exception(err): - raise - workflow.logger.warning( - "Failed to start tracing span %r for trace_id=%r; continuing without tracing", - name, - trace_id, - exc_info=True, - ) - _record_temporal_span_activity_dropped("start") - return None - else: - return await self._tracing_service.start_span( - trace_id=trace_id, - name=name, - input=input, - parent_id=parent_id, - data=data, - task_id=task_id, - ) - - async def end_span( - self, - trace_id: str, - span: Span, - start_to_close_timeout: timedelta = timedelta(seconds=5), - heartbeat_timeout: timedelta = timedelta(seconds=1), - retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY, - ) -> Span: - """ - End an existing span in the trace. - - Args: - trace_id (str): The trace ID for the span. - span (Span): The span to end. - start_to_close_timeout (timedelta): The start to close timeout for the span. - heartbeat_timeout (timedelta): The heartbeat timeout for the span. - retry_policy (RetryPolicy): The retry policy for the span. - - Returns: - Span: The ended span object. - """ - params = EndSpanParams( - trace_id=trace_id, - span=span, - ) - if in_temporal_workflow(): - try: - return await ActivityHelpers.execute_activity( - activity_name=TracingActivityName.END_SPAN, - request=params, - response_type=Span, - start_to_close_timeout=start_to_close_timeout, - retry_policy=retry_policy, - heartbeat_timeout=heartbeat_timeout, - ) - except (ActivityError, TemporalTimeoutError) as err: - if is_cancelled_exception(err): - raise - workflow.logger.warning( - "Failed to end tracing span %r for trace_id=%r; continuing without closing trace", - span.id, - trace_id, - exc_info=True, - ) - _record_temporal_span_activity_dropped("end") - return span - else: - return await self._tracing_service.end_span( - trace_id=trace_id, - span=span, - ) diff --git a/src/agentex/lib/adk/providers/__init__.py b/src/agentex/lib/adk/providers/__init__.py 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/openai_turn.py b/src/agentex/lib/adk/providers/_modules/openai_turn.py deleted file mode 100644 index 320642dfc..000000000 --- a/src/agentex/lib/adk/providers/_modules/openai_turn.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Back-compat shim: ``OpenAITurn`` and ``openai_usage_to_turn_usage`` now live -in ``agentex.lib.adk._modules._openai_turn``. - -Existing importers of -``agentex.lib.adk.providers._modules.openai_turn.{OpenAITurn,openai_usage_to_turn_usage}`` -keep working. -""" - -from agentex.lib.adk._modules._openai_turn import ( # noqa: F401 - OpenAITurn, - openai_usage_to_turn_usage, -) diff --git a/src/agentex/lib/adk/providers/_modules/sgp.py b/src/agentex/lib/adk/providers/_modules/sgp.py 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 86696a2b5..000000000 --- a/src/agentex/lib/adk/providers/_modules/sync_provider.py +++ /dev/null @@ -1,387 +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 agents.models.openai_provider import OpenAIProvider - -from agentex import AsyncAgentex -from agentex.lib.utils.logging import make_logger -from agentex.lib.core.tracing.tracer import AsyncTracer - -logger = make_logger(__name__) - - -def _serialize_item(item: Any) -> dict[str, Any]: - """ - Universal serializer for any item type from OpenAI Agents SDK. - - Uses model_dump() for Pydantic models, otherwise extracts attributes manually. - Filters out internal Pydantic fields that can't be serialized. - """ - if hasattr(item, "model_dump"): - # Pydantic model - use model_dump for proper serialization - try: - return item.model_dump(mode="json", exclude_unset=True) - except Exception: - # Fallback to dict conversion - return dict(item) if hasattr(item, "__iter__") else {} - else: - # Not a Pydantic model - extract attributes manually - item_dict = {} - for attr_name in dir(item): - if not attr_name.startswith("_") and attr_name not in ( - "model_fields", - "model_config", - "model_computed_fields", - ): - try: - attr_value = getattr(item, attr_name, None) - # Skip methods and None values - if attr_value is not None and not callable(attr_value): - # Convert to JSON-serializable format - if hasattr(attr_value, "model_dump"): - item_dict[attr_name] = attr_value.model_dump() - elif isinstance(attr_value, (str, int, float, bool, list, dict)): - item_dict[attr_name] = attr_value - else: - item_dict[attr_name] = str(attr_value) - except Exception: - # Skip attributes that can't be accessed - pass - return item_dict - - -class SyncStreamingModel(Model): - """Simple model wrapper that adds logging to stream_response and supports tracing. - - .. deprecated:: - Prefer the unified harness surface for new OpenAI Agents integrations: - wrap a ``Runner.run_streamed`` result in - ``agentex.lib.adk._modules._openai_turn.OpenAITurn`` and drive - delivery + tracing through ``UnifiedEmitter`` (see the - ``050_openai_agents`` / ``120_openai_agents`` tutorials). This - per-model tracing wrapper predates the harness and is - retained only for backwards compatibility; it will be removed in a - future release. No runtime warning is emitted. - """ - - def __init__( - self, - original_model: Model, - trace_id: str | None = None, - parent_span_id: str | None = None, - tracer: AsyncTracer | None = None, - ): - """Initialize with the original OpenAI model to wrap. - Args: - original_model: The OpenAI model instance to wrap - trace_id: Optional trace ID for distributed tracing - parent_span_id: Optional parent span ID for tracing hierarchy - tracer: Optional AsyncTracer for distributed tracing - """ - self.original_model = original_model - self.trace_id = trace_id - self.parent_span_id = parent_span_id - self.tracer = tracer - - @override - async def get_response( - self, - system_instructions: Optional[str], - input: Union[str, list[TResponseInputItem]], - model_settings: ModelSettings, - tools: list[Tool], - output_schema: Optional[AgentOutputSchemaBase], - handoffs: list[Handoff], - tracing: ModelTracing, - *, - previous_response_id: Optional[str] = None, - conversation_id: Optional[str] = None, - prompt: Any = None, - ) -> ModelResponse: - """Pass through to the original model's get_response with tracing support.""" - - # Wrap the request in a tracing span if tracer is available - if self.tracer and self.trace_id: - trace = self.tracer.trace(self.trace_id) - async with trace.span( - parent_id=self.parent_span_id, - name="run_agent", - input={ - "system_instructions": system_instructions, - "input": input, - "model_settings": str(model_settings) if model_settings else None, - "tools": [tool.name for tool in tools] if tools else [], - "output_schema": str(output_schema) if output_schema else None, - "handoffs": [str(h) for h in handoffs] if handoffs else [], - "previous_response_id": previous_response_id, - }, - ) as span: - # Build kwargs, excluding conversation_id if not supported - kwargs = { - "system_instructions": system_instructions, - "input": input, - "model_settings": model_settings, - "tools": tools, - "output_schema": output_schema, - "handoffs": handoffs, - "tracing": tracing, - "previous_response_id": previous_response_id, - "prompt": prompt, - } - - # Only add conversation_id if the model supports it - if hasattr(self.original_model, "supports_conversation_id"): - kwargs["conversation_id"] = conversation_id - - response = await self.original_model.get_response(**kwargs) - - # Set span output with structured data - if span and response: - new_items = [] - final_output = None - - # Extract final output text from response - response_final_output = getattr(response, "final_output", None) - if response_final_output: - final_output = response_final_output - - # Extract items from the response output - response_output = getattr(response, "output", None) - if response_output: - output_items = response_output if isinstance(response_output, list) else [response_output] - - for item in output_items: - try: - item_dict = _serialize_item(item) - if item_dict: - new_items.append(item_dict) - - # Extract final_output from message type if available - if item_dict.get("type") == "message" and not final_output: - content = item_dict.get("content", []) - if content and isinstance(content, list): - for content_part in content: - if isinstance(content_part, dict) and "text" in content_part: - final_output = content_part["text"] - break - except Exception as e: - logger.warning(f"Failed to serialize item in get_response: {e}") - continue - - span.output = { - "new_items": new_items, - "final_output": final_output, - } - - 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. - - .. deprecated:: - Prefer the unified harness surface for new OpenAI Agents integrations - (see :class:`SyncStreamingModel` and the ``OpenAITurn`` + - ``UnifiedEmitter`` pattern). This provider wrapper predates the harness - and is retained only for backwards compatibility; it will be removed in - a future release. No runtime warning is emitted. - """ - - def __init__(self, trace_id: str | None = None, parent_span_id: str | None = None, *args, **kwargs): - """Initialize the provider with tracing support. - Args: - trace_id: Optional trace ID for distributed tracing - parent_span_id: Optional parent span ID for tracing hierarchy - *args: Additional positional arguments for OpenAIProvider - **kwargs: Additional keyword arguments for OpenAIProvider - """ - super().__init__(*args, **kwargs) - self.trace_id = trace_id - self.parent_span_id = parent_span_id - - # Initialize AsyncTracer with client directly in the provider - if trace_id: - agentex_client = AsyncAgentex() - self.tracer = AsyncTracer(agentex_client) - else: - self.tracer = None - - @override - def get_model(self, model_name: Optional[str] = None) -> Model: - """Get a model wrapped with our logging capabilities and tracing. - Args: - model_name: The name of the model to retrieve - Returns: - A SyncStreamingModel that wraps the original OpenAI model - """ - # Get the original model from the parent class - original_model = super().get_model(model_name) - - # Wrap it with our logging capabilities and tracing info - wrapped_model = SyncStreamingModel(original_model, self.trace_id, self.parent_span_id, self.tracer) - - return wrapped_model - - -# The OpenAI streaming tap ``convert_openai_to_agentex_events`` now lives in -# ``agentex.lib.adk._modules._openai_sync``; re-exported here for back-compat. -from agentex.lib.adk._modules._openai_sync import ( # noqa: E402 - convert_openai_to_agentex_events as convert_openai_to_agentex_events, -) diff --git a/src/agentex/lib/adk/utils/__init__.py b/src/agentex/lib/adk/utils/__init__.py 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 c4b49a15e..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 load_agent_manifest -from agentex.lib.cli.handlers.agent_handlers import ( - run_agent, - build_agent, - parse_build_args, - prepare_cloud_build_context, -) -from agentex.lib.cli.handlers.deploy_handlers import ( - HelmError, - DeploymentError, - InputDeployOverrides, - deploy_agent, -) -from agentex.lib.cli.handlers.cleanup_handlers import cleanup_agent_workflows - -logger = make_logger(__name__) -console = Console() - -agents = typer.Typer() - - -@agents.command() -def get( - agent_id: str = typer.Argument(..., help="ID of the agent to get"), -): - """ - Get the agent with the given name. - """ - logger.info(f"Getting agent with ID: {agent_id}") - client = Agentex() - agent = client.agents.retrieve(agent_id=agent_id) - logger.info(f"Agent retrieved: {agent}") - print_json(data=agent.to_dict(), default=str) - - -@agents.command() -def list(): - """ - List all agents. - """ - logger.info("Listing all agents") - client = Agentex() - agents = client.agents.list() - logger.info(f"Agents retrieved: {agents}") - print_json(data=[agent.to_dict() for agent in agents], default=str) - - -@agents.command() -def delete( - agent_name: str = typer.Argument(..., help="Name of the agent to delete"), -): - """ - Delete the agent with the given name. - """ - logger.info(f"Deleting agent with name: {agent_name}") - client = Agentex() - client.agents.delete_by_name(agent_name=agent_name) - logger.info(f"Agent deleted: {agent_name}") - - -@agents.command() -def cleanup_workflows( - agent_name: str = typer.Argument(..., help="Name of the agent to cleanup workflows for"), - force: bool = typer.Option( - False, help="Force cleanup using direct Temporal termination (bypasses development check)" - ), -): - """ - Clean up all running workflows for an agent. - - By default, uses graceful cancellation via agent RPC. - With --force, directly terminates workflows via Temporal client. - This is a convenience command that does the same thing as 'agentex tasks cleanup'. - """ - try: - console.print(f"[blue]Cleaning up workflows for agent '{agent_name}'...[/blue]") - - cleanup_agent_workflows(agent_name=agent_name, force=force, development_only=True) - - console.print(f"[green]โœ“ Workflow cleanup completed for agent '{agent_name}'[/green]") - - except Exception as e: - console.print(f"[red]Cleanup failed: {str(e)}[/red]") - logger.exception("Agent workflow cleanup failed") - raise typer.Exit(1) from e - - -@agents.command() -def build( - manifest: str = typer.Option(..., help="Path to the manifest you want to use"), - registry: str | None = typer.Option(None, help="Registry URL for pushing the built image"), - repository_name: str | None = typer.Option(None, help="Repository name to use for the built image"), - platforms: str | None = typer.Option( - None, help="Platform to build the image for. Please enter a comma separated list of platforms." - ), - push: bool = typer.Option(False, help="Whether to push the image to the registry"), - secret: str | None = typer.Option( - None, - help="Docker build secret in the format 'id=secret-id,src=path-to-secret-file'", - ), - tag: str | None = typer.Option(None, help="Image tag to use (defaults to 'latest')"), - build_arg: builtins.list[str] | None = typer.Option( # noqa: B008 - None, - help="Docker build argument in the format 'KEY=VALUE' (can be used multiple times)", - ), -): - """ - 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 = load_agent_manifest(file_path=manifest) - agent_name = manifest_obj.agent.name - - console.print(f"[yellow]Cleaning up existing workflows for agent '{agent_name}'...[/yellow]") - cleanup_agent_workflows(agent_name=agent_name, force=False, development_only=True) - console.print("[green]โœ“ Pre-run cleanup completed[/green]") - - except Exception as e: - console.print(f"[yellow]โš  Pre-run cleanup failed: {str(e)}[/yellow]") - logger.warning(f"Pre-run cleanup failed: {e}") - - # Create debug configuration based on CLI flags - debug_config = None - if debug or debug_worker or debug_acp: - # Determine debug mode - if debug: - mode = DebugMode.BOTH - elif debug_worker and debug_acp: - mode = DebugMode.BOTH - elif debug_worker: - mode = DebugMode.WORKER - elif debug_acp: - mode = DebugMode.ACP - else: - mode = DebugMode.NONE - - debug_config = DebugConfig( - enabled=True, - mode=mode, - port=debug_port, - wait_for_attach=wait_for_debugger, - auto_port=False, # Use fixed port to match VS Code launch.json - ) - - console.print(f"[blue]๐Ÿ› Debug mode enabled: {mode.value}[/blue]") - if wait_for_debugger: - console.print("[yellow]โณ Processes will wait for debugger attachment[/yellow]") - - try: - run_agent(manifest_path=manifest, debug_config=debug_config) - except Exception as e: - typer.echo(f"Error running agent: {str(e)}", err=True) - logger.exception("Error running agent") - raise typer.Exit(1) from e - - -@agents.command() -def deploy( - cluster: str = typer.Option(..., help="Target cluster name (must match kubectl context)"), - manifest: str = typer.Option("manifest.yaml", help="Path to the manifest file"), - namespace: str | None = typer.Option( - None, - help="Override Kubernetes namespace (defaults to namespace from environments.yaml)", - ), - environment: str | None = typer.Option( - None, - help="Environment name (dev, prod, etc.) - must be defined in environments.yaml. If not provided, the namespace must be set explicitly.", - ), - tag: str | None = typer.Option(None, help="Override the image tag for deployment"), - repository: str | None = typer.Option(None, help="Override the repository for deployment"), - use_latest_chart: bool = typer.Option( - False, "--use-latest-chart", help="Fetch and use the latest Helm chart version from OCI registry" - ), - interactive: bool = typer.Option(True, "--interactive/--no-interactive", help="Enable interactive prompts"), -): - """Deploy an agent to a Kubernetes cluster using Helm""" - - console.print(Panel.fit("๐Ÿš€ [bold blue]Deploy Agent[/bold blue]", border_style="blue")) - - try: - # Validate manifest exists - manifest_path = Path(manifest) - if not manifest_path.exists(): - console.print(f"[red]Error:[/red] Manifest file not found: {manifest}") - raise typer.Exit(1) - - # Validate manifest and environments configuration - try: - _, environments_config = validate_manifest_and_environments( - str(manifest_path), required_environment=environment - ) - agent_env_config = environments_config.get_config_for_env(environment) - console.print(f"[green]โœ“[/green] Environment config validated: {environment}") - - except EnvironmentsValidationError as e: - error_msg = generate_helpful_error_message(e, "Environment validation failed") - console.print(f"[red]Configuration Error:[/red]\n{error_msg}") - raise typer.Exit(1) from e - except Exception as e: - console.print(f"[red]Error:[/red] Failed to validate configuration: {e}") - raise typer.Exit(1) from e - - # Load manifest for credential validation - manifest_obj = load_agent_manifest(str(manifest_path)) - - # Use namespace from environment config if not overridden - if not namespace and agent_env_config: - namespace_from_config = agent_env_config.kubernetes.namespace if agent_env_config.kubernetes else None - if namespace_from_config: - console.print(f"[blue]โ„น[/blue] Using namespace from environments.yaml: {namespace_from_config}") - namespace = namespace_from_config - else: - raise DeploymentError( - f"No namespace found in environments.yaml for environment: {environment}, and not passed in as --namespace" - ) - elif not namespace: - raise DeploymentError( - "No namespace provided, and not passed in as --namespace and no environment provided to read from an environments.yaml file" - ) - - # Confirm deployment (only in interactive mode) - console.print("\n[bold]Deployment Summary:[/bold]") - console.print(f" Manifest: {manifest}") - console.print(f" Environment: {environment}") - console.print(f" Cluster: {cluster}") - console.print(f" Namespace: {namespace}") - if tag: - console.print(f" Image Tag: {tag}") - if use_latest_chart: - console.print(" Chart Version: [cyan]latest (will be fetched)[/cyan]") - - if interactive: - proceed = questionary.confirm("Proceed with deployment?").ask() - proceed = handle_questionary_cancellation(proceed, "deployment confirmation") - - if not proceed: - console.print("Deployment cancelled") - raise typer.Exit(0) - else: - console.print("Proceeding with deployment (non-interactive mode)") - - check_and_switch_cluster_context(cluster) - if not validate_namespace(namespace, cluster): - console.print(f"[red]Error:[/red] Namespace '{namespace}' does not exist in cluster '{cluster}'") - raise typer.Exit(1) - - deploy_overrides = InputDeployOverrides(repository=repository, image_tag=tag) - - # Deploy agent - deploy_agent( - manifest_path=str(manifest_path), - cluster_name=cluster, - namespace=namespace, - deploy_overrides=deploy_overrides, - environment_name=environment, - use_latest_chart=use_latest_chart, - ) - - # Use the already loaded manifest object - release_name = f"{manifest_obj.agent.name}-{cluster}" - - console.print("\n[bold green]๐ŸŽ‰ Deployment completed successfully![/bold green]") - console.print("\nTo check deployment status:") - console.print(f" kubectl get pods -n {namespace}") - console.print(f" helm status {release_name} -n {namespace}") - - except (DeploymentError, HelmError) as e: - console.print(f"[red]Deployment failed:[/red] {str(e)}") - logger.exception("Deployment failed") - raise typer.Exit(1) from e - except Exception as e: - console.print(f"[red]Unexpected error:[/red] {str(e)}") - logger.exception("Unexpected error during deployment") - raise typer.Exit(1) from e diff --git a/src/agentex/lib/cli/commands/init.py b/src/agentex/lib/cli/commands/init.py deleted file mode 100644 index 9849e9bbc..000000000 --- a/src/agentex/lib/cli/commands/init.py +++ /dev/null @@ -1,448 +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" - TEMPORAL_CLAUDE_CODE = "temporal-claude-code" - TEMPORAL_CODEX = "temporal-codex" - DEFAULT = "default" - DEFAULT_LANGGRAPH = "default-langgraph" - DEFAULT_PYDANTIC_AI = "default-pydantic-ai" - DEFAULT_OPENAI_AGENTS = "default-openai-agents" - DEFAULT_CLAUDE_CODE = "default-claude-code" - DEFAULT_CODEX = "default-codex" - SYNC = "sync" - SYNC_OPENAI_AGENTS = "sync-openai-agents" - SYNC_OPENAI_AGENTS_LOCAL_SANDBOX = "sync-openai-agents-local-sandbox" - SYNC_LANGGRAPH = "sync-langgraph" - SYNC_PYDANTIC_AI = "sync-pydantic-ai" - SYNC_CLAUDE_CODE = "sync-claude-code" - SYNC_CODEX = "sync-codex" - - -def render_template( - template_path: str, context: Dict[str, Any], template_type: TemplateType -) -> str: - """Render a template with the given context""" - env = Environment(loader=FileSystemLoader(TEMPLATES_DIR / template_type.value)) - template = env.get_template(template_path) - return template.render(**context) - - -def create_project_structure( - path: Path, context: Dict[str, Any], template_type: TemplateType, use_uv: bool -): - """Create the project structure from templates""" - # Create project directory - project_dir: Path = path / context["project_name"] - project_dir.mkdir(parents=True, exist_ok=True) - - # Create project/code directory - code_dir: Path = project_dir / "project" - code_dir.mkdir(parents=True, exist_ok=True) - - # Create __init__.py - (code_dir / "__init__.py").touch() - - # Define project files based on template type - project_files = { - TemplateType.TEMPORAL: ["acp.py", "workflow.py", "run_worker.py"], - TemplateType.TEMPORAL_OPENAI_AGENTS: ["acp.py", "workflow.py", "run_worker.py", "activities.py"], - TemplateType.TEMPORAL_PYDANTIC_AI: ["acp.py", "workflow.py", "run_worker.py", "agent.py", "tools.py"], - TemplateType.TEMPORAL_LANGGRAPH: ["acp.py", "workflow.py", "run_worker.py", "graph.py", "tools.py"], - TemplateType.TEMPORAL_CLAUDE_CODE: ["acp.py", "workflow.py", "run_worker.py", "activities.py"], - TemplateType.TEMPORAL_CODEX: ["acp.py", "workflow.py", "run_worker.py", "activities.py"], - TemplateType.DEFAULT: ["acp.py"], - TemplateType.DEFAULT_LANGGRAPH: ["acp.py", "graph.py", "tools.py"], - TemplateType.DEFAULT_PYDANTIC_AI: ["acp.py", "agent.py", "tools.py"], - TemplateType.DEFAULT_OPENAI_AGENTS: ["acp.py"], - TemplateType.DEFAULT_CLAUDE_CODE: ["acp.py"], - TemplateType.DEFAULT_CODEX: ["acp.py"], - TemplateType.SYNC: ["acp.py"], - TemplateType.SYNC_OPENAI_AGENTS: ["acp.py"], - TemplateType.SYNC_OPENAI_AGENTS_LOCAL_SANDBOX: ["acp.py", "agent.py", "tools.py"], - TemplateType.SYNC_LANGGRAPH: ["acp.py", "graph.py", "tools.py"], - TemplateType.SYNC_PYDANTIC_AI: ["acp.py", "agent.py", "tools.py"], - TemplateType.SYNC_CLAUDE_CODE: ["acp.py"], - TemplateType.SYNC_CODEX: ["acp.py"], - }[template_type] - - # Create project/code files - for template in project_files: - template_path = f"project/{template}.j2" - output_path = code_dir / template - output_path.write_text(render_template(template_path, context, template_type)) - - # Create root files - root_templates = { - ".dockerignore.j2": ".dockerignore", - ".env.example.j2": ".env.example", - "manifest.yaml.j2": "manifest.yaml", - "README.md.j2": "README.md", - "environments.yaml.j2": "environments.yaml", - } - - # Add package management file based on uv choice - if use_uv: - root_templates["pyproject.toml.j2"] = "pyproject.toml" - root_templates["Dockerfile-uv.j2"] = "Dockerfile" - else: - root_templates["requirements.txt.j2"] = "requirements.txt" - root_templates["Dockerfile.j2"] = "Dockerfile" - - # Add development notebook for agents - root_templates["dev.ipynb.j2"] = "dev.ipynb" - - for template, output in root_templates.items(): - output_path = project_dir / output - output_path.write_text(render_template(template, context, template_type)) - - console.print(f"\n[green]โœ“[/green] Created project structure at: {project_dir}") - - -def get_project_context(answers: Dict[str, Any], project_path: Path, manifest_root: Path) -> Dict[str, Any]: # noqa: ARG001 - """Get the project context from user answers""" - # Use agent_directory_name as project_name - project_name = answers["agent_directory_name"].replace("-", "_") - - # Now, this is actually the exact same as the project_name because we changed the build root to be ../ - project_path_from_build_root = project_name - - return { - **answers, - "project_name": project_name, - "workflow_class": "".join( - word.capitalize() for word in answers["agent_name"].split("-") - ) - + "Workflow", - "workflow_name": answers["agent_name"], - "queue_name": project_name + "_queue", - "project_path_from_build_root": project_path_from_build_root, - } - - -def init(): - """Initialize a new agent project""" - console.print( - Panel.fit( - "๐Ÿค– [bold blue]Initialize New Agent Project[/bold blue]", - border_style="blue", - ) - ) - - # Use a Rich table for template descriptions - table = Table(show_header=True, header_style="bold blue") - table.add_column("Template", style="cyan", no_wrap=True) - table.add_column("Description", style="white") - table.add_row( - "[bold cyan]Sync ACP[/bold cyan]", - "Synchronous agent that processes one request per task with a simple request-response pattern. Best for low-latency use cases, FAQ bots, translation services, and data lookups.", - ) - table.add_row( - "[bold cyan]Async - ACP Only[/bold cyan]", - "Asynchronous, non-blocking agent that can process multiple concurrent requests. Best for straightforward asynchronous agents that don't need durable execution. Good for asynchronous workflows, stateful applications, and multi-step analysis.", - ) - table.add_row( - "[bold cyan]Async - Temporal[/bold cyan]", - "Asynchronous, non-blocking agent with durable execution for all steps. Best for production-grade agents that require complex multi-step tool calls, human-in-the-loop approvals, and long-running processes that require transactional reliability.", - ) - console.print() - console.print(table) - console.print() - - def validate_agent_name(text: str) -> bool | str: - """Validate agent name follows required format""" - is_valid = len(text) >= 1 and text.replace("-", "").isalnum() and text.islower() - if not is_valid: - return "Invalid name. Use only lowercase letters, numbers, and hyphens. Examples: 'my-agent', 'newsbot'" - return True - - # Gather project information - template_type = questionary.select( - "What type of template would you like to create?", - choices=[ - {"name": "Sync ACP", "value": "sync_submenu"}, - {"name": "Async - ACP Only", "value": "async_submenu"}, - {"name": "Async - Temporal", "value": "temporal_submenu"}, - ], - ).ask() - if not template_type: - return - - # If a submenu was selected, show sub-menu for variants - if template_type == "async_submenu": - template_type = questionary.select( - "Which Async template would you like to use?", - choices=[ - {"name": "Basic Async ACP", "value": TemplateType.DEFAULT}, - {"name": "Async ACP + OpenAI Agents SDK", "value": TemplateType.DEFAULT_OPENAI_AGENTS}, - {"name": "Async ACP + LangGraph", "value": TemplateType.DEFAULT_LANGGRAPH}, - {"name": "Async ACP + Pydantic AI", "value": TemplateType.DEFAULT_PYDANTIC_AI}, - {"name": "Async ACP + Claude Code", "value": TemplateType.DEFAULT_CLAUDE_CODE}, - {"name": "Async ACP + Codex", "value": TemplateType.DEFAULT_CODEX}, - ], - ).ask() - if not template_type: - return - elif template_type == "temporal_submenu": - template_type = questionary.select( - "Which Temporal template would you like to use?", - choices=[ - {"name": "Basic Temporal", "value": TemplateType.TEMPORAL}, - {"name": "Temporal + OpenAI Agents SDK (Recommended)", "value": TemplateType.TEMPORAL_OPENAI_AGENTS}, - {"name": "Temporal + Pydantic AI", "value": TemplateType.TEMPORAL_PYDANTIC_AI}, - {"name": "Temporal + LangGraph", "value": TemplateType.TEMPORAL_LANGGRAPH}, - {"name": "Temporal + Claude Code", "value": TemplateType.TEMPORAL_CLAUDE_CODE}, - {"name": "Temporal + Codex", "value": TemplateType.TEMPORAL_CODEX}, - ], - ).ask() - if not template_type: - return - elif template_type == "sync_submenu": - template_type = questionary.select( - "Which Sync template would you like to use?", - choices=[ - {"name": "Basic Sync ACP", "value": TemplateType.SYNC}, - {"name": "Sync ACP + OpenAI Agents SDK (Recommended)", "value": TemplateType.SYNC_OPENAI_AGENTS}, - {"name": "Sync ACP + OpenAI Agents SDK + Local Sandbox", "value": TemplateType.SYNC_OPENAI_AGENTS_LOCAL_SANDBOX}, - {"name": "Sync ACP + LangGraph", "value": TemplateType.SYNC_LANGGRAPH}, - {"name": "Sync ACP + Pydantic AI", "value": TemplateType.SYNC_PYDANTIC_AI}, - {"name": "Sync ACP + Claude Code", "value": TemplateType.SYNC_CLAUDE_CODE}, - {"name": "Sync ACP + Codex", "value": TemplateType.SYNC_CODEX}, - ], - ).ask() - if not template_type: - return - - project_path = questionary.path( - "Where would you like to create your project?", default="." - ).ask() - if not project_path: - return - - agent_name = questionary.text( - "What's your agent name? (letters, numbers, and hyphens only)", - validate=validate_agent_name, - ).ask() - if not agent_name: - return - - agent_directory_name = questionary.text( - "What do you want to name the project folder for your agent?", - default=agent_name, - ).ask() - if not agent_directory_name: - return - - description = questionary.text( - "Provide a brief description of your agent:", default="An Agentex agent" - ).ask() - if not description: - return - - use_uv = questionary.select( - "Would you like to use uv for package management?", - choices=[ - {"name": "Yes (Recommended)", "value": True}, - {"name": "No", "value": False}, - ], - ).ask() - - answers = { - "template_type": template_type, - "project_path": project_path, - "agent_name": agent_name, - "agent_directory_name": agent_directory_name, - "description": description, - "use_uv": use_uv, - } - - # Derive all names from agent_directory_name and path - project_path = Path(answers["project_path"]).resolve() - manifest_root = Path("../../") - - # Get project context - context = get_project_context(answers, project_path, manifest_root) - context["template_type"] = answers["template_type"].value - context["use_uv"] = answers["use_uv"] - - # Create project structure - create_project_structure( - project_path, context, answers["template_type"], answers["use_uv"] - ) - - # Show success message - console.print() - success_text = Text("โœ… Project created successfully!", style="bold green") - success_panel = Panel( - success_text, - border_style="green", - padding=(0, 2), - title="[bold white]Status[/bold white]", - title_align="left" - ) - console.print(success_panel) - - # Main header - console.print() - console.print(Rule("[bold blue]Next Steps[/bold blue]", style="blue")) - console.print() - - # Local Development Section - local_steps = Text() - local_steps.append("1. ", style="bold white") - local_steps.append("Navigate to your project directory:\n", style="white") - local_steps.append(f" cd {project_path}/{context['project_name']}\n\n", style="dim cyan") - - local_steps.append("2. ", style="bold white") - local_steps.append("Review the generated files. ", style="white") - local_steps.append("project/acp.py", style="yellow") - local_steps.append(" is your agent's entrypoint.\n", style="white") - local_steps.append(" See ", style="dim white") - local_steps.append("https://agentex.sgp.scale.com/docs", style="blue underline") - local_steps.append(" for how to customize different agent types", style="dim white") - local_steps.append("\n\n", style="white") - - local_steps.append("3. ", style="bold white") - local_steps.append("Set up your environment and test locally ", style="white") - local_steps.append("(no deployment needed)", style="dim white") - local_steps.append(":\n", style="white") - local_steps.append(" uv venv && uv sync && source .venv/bin/activate", style="dim cyan") - local_steps.append("\n agentex agents run --manifest manifest.yaml", style="dim cyan") - - local_panel = Panel( - local_steps, - title="[bold blue]Development Setup[/bold blue]", - title_align="left", - border_style="blue", - padding=(1, 2) - ) - console.print(local_panel) - console.print() - - # Prerequisites Note - prereq_text = Text() - prereq_text.append("The above is all you need for local development. Once you're ready for production, read this box and below.\n\n", style="white") - - prereq_text.append("โ€ข ", style="bold white") - prereq_text.append("Prerequisites for Production: ", style="bold yellow") - prereq_text.append("You need Agentex hosted on a Kubernetes cluster.\n", style="white") - prereq_text.append(" See ", style="dim white") - prereq_text.append("https://agentex.sgp.scale.com/docs", style="blue underline") - prereq_text.append(" for setup instructions. ", style="dim white") - prereq_text.append("Scale GenAI Platform (SGP) customers", style="dim cyan") - prereq_text.append(" already have this setup as part of their enterprise license.\n\n", style="dim white") - - prereq_text.append("โ€ข ", style="bold white") - prereq_text.append("Best Practice: ", style="bold blue") - prereq_text.append("Use CI/CD pipelines for production deployments, not manual commands.\n", style="white") - prereq_text.append(" Commands below demonstrate Agentex's quick deployment capabilities.", style="dim white") - - prereq_panel = Panel( - prereq_text, - border_style="yellow", - padding=(1, 2) - ) - console.print(prereq_panel) - console.print() - - # Production Setup Section (includes deployment) - prod_steps = Text() - prod_steps.append("4. ", style="bold white") - prod_steps.append("Configure where to push your container image", style="white") - prod_steps.append(":\n", style="white") - prod_steps.append(" Edit ", style="dim white") - prod_steps.append("manifest.yaml", style="dim yellow") - prod_steps.append(" โ†’ ", style="dim white") - prod_steps.append("deployment.image.repository", style="dim yellow") - prod_steps.append(" โ†’ replace ", style="dim white") - prod_steps.append('""', style="dim red") - prod_steps.append(" with your registry", style="dim white") - prod_steps.append("\n Examples: ", style="dim white") - prod_steps.append("123456789012.dkr.ecr.us-west-2.amazonaws.com/my-agent", style="dim blue") - prod_steps.append(", ", style="dim white") - prod_steps.append("gcr.io/my-project", style="dim blue") - prod_steps.append(", ", style="dim white") - prod_steps.append("myregistry.azurecr.io", style="dim blue") - prod_steps.append("\n\n", style="white") - - prod_steps.append("5. ", style="bold white") - prod_steps.append("Build your agent as a container and push to registry", style="white") - prod_steps.append(":\n", style="white") - prod_steps.append(" agentex agents build --manifest manifest.yaml --registry --push", style="dim cyan") - prod_steps.append("\n\n", style="white") - - prod_steps.append("6. ", style="bold white") - prod_steps.append("Upload secrets to cluster ", style="white") - prod_steps.append("(API keys, credentials your agent needs)", style="dim white") - prod_steps.append(":\n", style="white") - prod_steps.append(" agentex secrets sync --manifest manifest.yaml --cluster your-cluster", style="dim cyan") - prod_steps.append("\n ", style="white") - prod_steps.append("Note: ", style="dim yellow") - prod_steps.append("Secrets are ", style="dim white") - prod_steps.append("never stored in manifest.yaml", style="dim red") - prod_steps.append(". You provide them via ", style="dim white") - prod_steps.append("--values file", style="dim blue") - prod_steps.append(" or interactive prompts", style="dim white") - prod_steps.append("\n\n", style="white") - - prod_steps.append("7. ", style="bold white") - prod_steps.append("Deploy your agent to run on the cluster", style="white") - prod_steps.append(":\n", style="white") - prod_steps.append(" agentex agents deploy --cluster your-cluster --namespace your-namespace", style="dim cyan") - prod_steps.append("\n\n", style="white") - prod_steps.append("Note: These commands use Helm charts hosted by Scale to deploy agents.", style="dim italic") - - prod_panel = Panel( - prod_steps, - title="[bold magenta]Production Setup & Deployment[/bold magenta]", - title_align="left", - border_style="magenta", - padding=(1, 2) - ) - console.print(prod_panel) - - # Professional footer with helpful context - console.print() - console.print(Rule(style="dim white")) - - # Add helpful context about the workflow - help_text = Text() - help_text.append("โ„น๏ธ ", style="blue") - help_text.append("Quick Start: ", style="bold white") - help_text.append("Steps 1-3 for local development. Steps 4-7 require Agentex cluster for production.", style="dim white") - console.print(" ", help_text) - - tip_text = Text() - tip_text.append("๐Ÿ’ก ", style="yellow") - tip_text.append("Need help? ", style="bold white") - tip_text.append("Use ", style="dim white") - tip_text.append("agentex --help", style="cyan") - tip_text.append(" or ", style="dim white") - tip_text.append("agentex [command] --help", style="cyan") - tip_text.append(" for detailed options", style="dim white") - console.print(" ", tip_text) - console.print() diff --git a/src/agentex/lib/cli/commands/main.py b/src/agentex/lib/cli/commands/main.py 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 fae21e63b..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 load_agent_manifest -from agentex.lib.cli.handlers.secret_handlers import ( - get_secret, - sync_secrets, - delete_secret, - get_kubernetes_secrets_by_type, -) - -logger = make_logger(__name__) -console = Console() - -secrets = typer.Typer() - - -@secrets.command() -def list( - namespace: str = typer.Option( - "agentex-agents", help="Kubernetes namespace to list secrets from" - ), - cluster: str | None = typer.Option( - None, help="Cluster context to use (defaults to current context)" - ), -): - """List names of available secrets""" - logger.info(f"Listing secrets in namespace: {namespace}") - - if cluster: - check_and_switch_cluster_context(cluster) - if not validate_namespace(namespace, cluster): - console.print( - f"[red]Error:[/red] Namespace '{namespace}' does not exist in cluster '{cluster}'" - ) - raise typer.Exit(1) - - secrets_list = get_kubernetes_secrets_by_type(namespace=namespace, context=cluster) - print_json(data=secrets_list) - - -@secrets.command() -def get( - name: str = typer.Argument(..., help="Name of the secret to get"), - namespace: str = typer.Option( - "agentex-agents", help="Kubernetes namespace for the secret" - ), - cluster: str | None = typer.Option( - None, help="Cluster context to use (defaults to current context)" - ), -): - """Get details about a secret""" - logger.info(f"Getting secret: {name} from namespace: {namespace}") - - if cluster: - check_and_switch_cluster_context(cluster) - if not validate_namespace(namespace, cluster): - console.print( - f"[red]Error:[/red] Namespace '{namespace}' does not exist in cluster '{cluster}'" - ) - raise typer.Exit(1) - - secret = get_secret(name=name, namespace=namespace, context=cluster) - print_json(data=secret) - - -@secrets.command() -def delete( - name: str = typer.Argument(..., help="Name of the secret to delete"), - namespace: str = typer.Option( - "agentex-agents", help="Kubernetes namespace for the secret" - ), - cluster: str | None = typer.Option( - None, help="Cluster context to use (defaults to current context)" - ), -): - """Delete a secret""" - logger.info(f"Deleting secret: {name} from namespace: {namespace}") - - if cluster: - check_and_switch_cluster_context(cluster) - if not validate_namespace(namespace, cluster): - console.print( - f"[red]Error:[/red] Namespace '{namespace}' does not exist in cluster '{cluster}'" - ) - raise typer.Exit(1) - - delete_secret(name=name, namespace=namespace, context=cluster) - - -@secrets.command() -def sync( - manifest: str = typer.Option(..., help="Path to the manifest file"), - # TODO: should cluster be here or be in manifest as well? - cluster: str = typer.Option(..., "--cluster", help="Cluster to sync secrets to"), - interactive: bool = typer.Option( - True, "--interactive/--no-interactive", help="Enable interactive prompts" - ), - namespace: str | None = typer.Option( - None, - help="Kubernetes namespace to deploy to (required in non-interactive mode)", - ), - values: str = typer.Option(None, "--values", help="Path to the values file"), -): - """Sync secrets from the cluster to the local environment""" - console.print( - Panel.fit("๐Ÿš€ [bold blue]Sync Secrets[/bold blue]", border_style="blue") - ) - - manifest_path = Path(manifest) - if not manifest_path.exists(): - console.print(f"[red]Error:[/red] Manifest file not found: {manifest}") - raise typer.Exit(1) - - # In non-interactive mode, require namespace - if not interactive and not namespace: - console.print( - "[red]Error:[/red] --namespace is required in non-interactive mode" - ) - raise typer.Exit(1) - - # Get namespace if not provided (only in interactive mode) - if not namespace: - namespace = questionary.text( - "Enter Kubernetes namespace:", default="default" - ).ask() - namespace = handle_questionary_cancellation(namespace, "namespace input") - - if not namespace: - console.print("Deployment cancelled") - raise typer.Exit(0) - - if values: - values_path = Path(values) - if not values_path.exists(): - console.print(f"[red]Error:[/red] Values file not found: {values_path}") - raise typer.Exit(1) - - # Validate cluster and namespace - check_and_switch_cluster_context(cluster) - if not validate_namespace(namespace, cluster): - console.print( - f"[red]Error:[/red] Namespace '{namespace}' does not exist in cluster '{cluster}'" - ) - raise typer.Exit(1) - - agent_manifest = load_agent_manifest(file_path=manifest) - - # Always call sync_secrets - it will handle the case of no credentials - sync_secrets( - manifest_obj=agent_manifest, - cluster=cluster, - namespace=namespace, - interactive=interactive, - values_path=str(values) if values else None, - ) - - console.print("[green]Successfully synced secrets[/green]") diff --git a/src/agentex/lib/cli/commands/tasks.py b/src/agentex/lib/cli/commands/tasks.py 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 1f2ccc7ef..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 BuildContextManager, load_agent_manifest, build_context_manager - -logger = make_logger(__name__) -console = Console() - - -class DockerBuildError(Exception): - """An error occurred during docker build""" - - -class CloudBuildContext(NamedTuple): - """Contains the prepared build context for cloud builds.""" - - archive_bytes: bytes - dockerfile_path: str - agent_name: str - tag: str - image_name: str - build_context_size_kb: float - - -def build_agent( - manifest_path: str, - registry_url: str, - repository_name: str | None, - platforms: list[str], - push: bool = False, - secret: str | None = None, - tag: str | None = None, - build_args: list[str] | None = None, -) -> 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 = load_agent_manifest(file_path=manifest_path) - build_context_root = (Path(manifest_path).parent / agent_manifest.build.context.root).resolve() - - repository_name = repository_name or agent_manifest.agent.name - - # Prepare image name - if registry_url: - image_name = f"{registry_url}/{repository_name}" - else: - image_name = repository_name - - if tag: - image_name = f"{image_name}:{tag}" - else: - image_name = f"{image_name}:latest" - - with build_context_manager(agent_manifest, build_context_root) as build_context: - logger.info(f"Building image {image_name} locally...") - - # Log build context information for debugging - logger.info(f"Build context path: {build_context.path}") - logger.info( - f"Dockerfile path: {build_context.path / build_context.dockerfile_path}" # type: ignore[operator] - ) - - try: - # Prepare build arguments - docker_build_kwargs = { - "context_path": str(build_context.path), - "file": str(build_context.path / build_context.dockerfile_path), # type: ignore[operator] - "tags": [image_name], - "platforms": platforms, - } - - # Add Docker build args if provided - if build_args: - docker_build_args = {} - for arg in build_args: - if "=" in arg: - key, value = arg.split("=", 1) - docker_build_args[key] = value - else: - logger.warning(f"Invalid build arg format: {arg}. Expected KEY=VALUE") - - if docker_build_args: - docker_build_kwargs["build_args"] = docker_build_args - logger.info(f"Using build args: {list(docker_build_args.keys())}") - - # Add secret if provided - if secret: - docker_build_kwargs["secrets"] = [secret] - - if push: - # Build and push in one step for multi-platform builds - logger.info("Building and pushing image...") - docker_build_kwargs["push"] = True # Push directly after build for multi-platform - docker.buildx.build(**docker_build_kwargs) - - logger.info(f"Successfully built and pushed {image_name}") - else: - # Build only - logger.info("Building image...") - docker.buildx.build(**docker_build_kwargs) - - logger.info(f"Successfully built {image_name}") - - except DockerException as error: - error_msg = error.stderr if error.stderr else str(error) - action = "build or push" if push else "build" - logger.error(f"{action.capitalize()} failed: {error_msg}", exc_info=True) - raise DockerBuildError( - f"Docker {action} failed: {error_msg}\n" - f"Build context: {build_context.path}\n" - f"Dockerfile path: {build_context.dockerfile_path}" - ) from error - - return image_name - - -def run_agent(manifest_path: str, debug_config: "DebugConfig | None" = None): - """Run an agent locally from the given manifest""" - import sys - import signal - import asyncio - - # Flag to track if we're shutting down - shutting_down = False - - def signal_handler(signum, _frame): - """Handle signals by raising KeyboardInterrupt""" - nonlocal shutting_down - if shutting_down: - # If we're already shutting down and get another signal, force exit - logger.info(f"Force exit on signal {signum}") - sys.exit(1) - - shutting_down = True - logger.info(f"Received signal {signum}, shutting down...") - raise KeyboardInterrupt() - - # Set up signal handling for the main thread - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - - try: - asyncio.run(_run_agent(manifest_path, debug_config)) - except KeyboardInterrupt: - logger.info("Shutdown completed.") - sys.exit(0) - except RunError as e: - raise RuntimeError(str(e)) from e - - -def parse_build_args(build_args: list[str] | None) -> dict[str, str]: - """Parse build arguments from KEY=VALUE format to a dictionary. - - Args: - build_args: List of build arguments in KEY=VALUE format - - Returns: - Dictionary mapping keys to values - """ - result: dict[str, str] = {} - if not build_args: - return result - - for arg in build_args: - if "=" in arg: - key, value = arg.split("=", 1) - result[key] = value - else: - logger.warning(f"Invalid build arg format: {arg}. Expected KEY=VALUE") - - return result - - -def prepare_cloud_build_context( - manifest_path: str, - tag: str | None = None, - build_args: list[str] | None = None, -) -> CloudBuildContext: - """Prepare the build context for cloud-based container builds. - - Reads the manifest, prepares the build context by copying files according to - the include_paths and dockerignore, then creates a compressed tar.gz archive - ready for upload to a cloud build service. - - Args: - manifest_path: Path to the agent manifest file - tag: Image tag override (if None, reads from manifest's deployment.image.tag) - build_args: List of build arguments in KEY=VALUE format - - Returns: - CloudBuildContext containing the archive bytes, dockerfile path, and metadata - """ - agent_manifest = load_agent_manifest(file_path=manifest_path) - build_context_root = (Path(manifest_path).parent / agent_manifest.build.context.root).resolve() - - agent_name = agent_manifest.agent.name - dockerfile_path = agent_manifest.build.context.dockerfile - - # Validate that the Dockerfile exists - full_dockerfile_path = build_context_root / dockerfile_path - if not full_dockerfile_path.exists(): - raise FileNotFoundError( - f"Dockerfile not found at: {full_dockerfile_path}\n" - f"Check that 'build.context.dockerfile' in your manifest points to an existing file." - ) - if not full_dockerfile_path.is_file(): - raise ValueError( - f"Dockerfile path is not a file: {full_dockerfile_path}\n" - f"'build.context.dockerfile' must point to a file, not a directory." - ) - - # Get tag and repository from manifest if not provided - if tag is None: - if agent_manifest.deployment and agent_manifest.deployment.image: - tag = agent_manifest.deployment.image.tag - else: - tag = "latest" - - # Get repository name from manifest (just the repo name, not the full registry URL) - if agent_manifest.deployment and agent_manifest.deployment.image: - repository = agent_manifest.deployment.image.repository - if repository: - # Extract just the repo name (last part after any slashes) - image_name = repository.split("/")[-1] - else: - image_name = "" - else: - image_name = "" - - logger.info(f"Agent: {agent_name}") - logger.info(f"Image name: {image_name}") - logger.info(f"Build context root: {build_context_root}") - logger.info(f"Dockerfile: {dockerfile_path}") - logger.info(f"Tag: {tag}") - - if agent_manifest.build.context.include_paths: - logger.info(f"Include paths: {agent_manifest.build.context.include_paths}") - - parsed_build_args = parse_build_args(build_args) - if parsed_build_args: - logger.info(f"Build args: {list(parsed_build_args.keys())}") - - logger.info("Preparing build context...") - - with build_context_manager(agent_manifest, build_context_root) as build_context: - # Compress the prepared context using the static zipped method - with BuildContextManager.zipped(root_path=build_context.path) as archive_buffer: - archive_bytes = archive_buffer.read() - - build_context_size_kb = len(archive_bytes) / 1024 - logger.info(f"Build context size: {build_context_size_kb:.1f} KB") - - return CloudBuildContext( - archive_bytes=archive_bytes, - dockerfile_path=build_context.dockerfile_path, - agent_name=agent_name, - tag=tag, - image_name=image_name, - build_context_size_kb=build_context_size_kb, - ) diff --git a/src/agentex/lib/cli/handlers/cleanup_handlers.py b/src/agentex/lib/cli/handlers/cleanup_handlers.py 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 605d91709..000000000 --- a/src/agentex/lib/cli/handlers/deploy_handlers.py +++ /dev/null @@ -1,588 +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.config.agent_config import AgentConfig -from agentex.config.agent_manifest import AgentManifest -from agentex.lib.cli.utils.exceptions import HelmError, DeploymentError -from agentex.lib.cli.utils.path_utils import PathResolutionError, calculate_docker_acp_module -from agentex.config.environment_config import OciRegistryConfig, AgentEnvironmentConfig -from agentex.lib.environment_variables import EnvVarKeys -from agentex.lib.cli.utils.kubectl_utils import check_and_switch_cluster_context -from agentex.lib.sdk.config.agent_manifest import load_agent_manifest -from agentex.lib.sdk.config.environment_config import load_environments_config_from_manifest_dir - -logger = make_logger(__name__) -console = Console() - -TEMPORAL_WORKER_KEY = "temporal-worker" -DEFAULT_HELM_CHART_VERSION = "0.1.9" - - -class InputDeployOverrides(BaseModel): - repository: str | None = Field(default=None, description="Override the repository for deployment") - image_tag: str | None = Field(default=None, description="Override the image tag for deployment") - - -def check_helm_installed() -> bool: - """Check if helm is installed and available""" - try: - result = subprocess.run(["helm", "version", "--short"], capture_output=True, text=True, check=True) - logger.info(f"Helm version: {result.stdout.strip()}") - return True - except (subprocess.CalledProcessError, FileNotFoundError): - return False - - -def add_helm_repo(helm_repository_name: str, helm_repository_url: str) -> None: - """Add the agentex helm repository if not already added (classic mode)""" - try: - # Check if repo already exists - result = subprocess.run(["helm", "repo", "list"], capture_output=True, text=True, check=True) - - if helm_repository_name not in result.stdout: - console.print("Adding agentex helm repository...") - subprocess.run( - [ - "helm", - "repo", - "add", - helm_repository_name, - helm_repository_url, - ], - check=True, - ) - else: - logger.info("Helm repository already exists. Running update...") - - subprocess.run(["helm", "repo", "update"], check=True) - console.print("[green]โœ“[/green] Helm repository update successfully") - - except subprocess.CalledProcessError as e: - raise HelmError(f"Failed to add helm repository: {e}") from e - - -def login_to_gar_registry(oci_registry: str) -> None: - """Auto-login to Google Artifact Registry using gcloud credentials. - - Args: - oci_registry: The GAR registry URL (e.g., 'us-west1-docker.pkg.dev/project-id/repo-name') - """ - try: - # Extract the registry host (e.g., 'us-west1-docker.pkg.dev') - registry_host = oci_registry.split("/")[0] - - # Get access token from gcloud - console.print(f"[blue]โ„น[/blue] Authenticating with Google Artifact Registry: {registry_host}") - result = subprocess.run( - ["gcloud", "auth", "print-access-token"], - capture_output=True, - text=True, - check=True, - ) - access_token = result.stdout.strip() - - # Login to helm registry using the access token - subprocess.run( - [ - "helm", - "registry", - "login", - registry_host, - "--username", - "oauth2accesstoken", - "--password-stdin", - ], - input=access_token, - text=True, - check=True, - ) - console.print(f"[green]โœ“[/green] Authenticated with GAR: {registry_host}") - - except subprocess.CalledProcessError as e: - raise HelmError( - f"Failed to authenticate with Google Artifact Registry: {e}\n" - "Ensure you are logged in with 'gcloud auth login' and have access to the registry." - ) from e - except FileNotFoundError: - raise HelmError( - "gcloud CLI not found. Please install the Google Cloud SDK: https://cloud.google.com/sdk/docs/install" - ) from None - - -def get_latest_gar_chart_version(oci_registry: str, chart_name: str = "agentex-agent") -> str: - """Fetch the latest version of a Helm chart from Google Artifact Registry. - - GAR stores Helm chart versions as tags (e.g., '0.1.9'), not as versions (which are SHA digests). - This function lists tags sorted by creation time and returns the most recent one. - - Args: - oci_registry: The GAR registry URL (e.g., 'us-west1-docker.pkg.dev/project-id/repo-name') - chart_name: Name of the Helm chart - - Returns: - The latest version string (e.g., '0.2.0') - """ - try: - # Parse the OCI registry URL to extract components - # Format: REGION-docker.pkg.dev/PROJECT/REPOSITORY - parts = oci_registry.split("/") - if len(parts) < 3: - raise HelmError( - f"Invalid OCI registry format: {oci_registry}. " - "Expected format: REGION-docker.pkg.dev/PROJECT/REPOSITORY" - ) - - location = parts[0].replace("-docker.pkg.dev", "") - project = parts[1] - repository = parts[2] - - console.print(f"[blue]โ„น[/blue] Fetching latest chart version from GAR...") - - # Use gcloud to list tags (not versions - versions are SHA digests) - # Tags contain the semantic versions like '0.1.9' - result = subprocess.run( - [ - "gcloud", - "artifacts", - "tags", - "list", - f"--repository={repository}", - f"--location={location}", - f"--project={project}", - f"--package={chart_name}", - "--sort-by=~createTime", - "--limit=1", - "--format=value(tag)", - ], - capture_output=True, - text=True, - check=True, - ) - - output = result.stdout.strip() - if not output: - raise HelmError(f"No tags found for chart '{chart_name}' in {oci_registry}") - - # The output is the tag name (semantic version) - version = output - console.print(f"[green]โœ“[/green] Latest chart version: {version}") - return version - - except subprocess.CalledProcessError as e: - raise HelmError( - f"Failed to fetch chart tags from GAR: {e.stderr}\nEnsure you have access to the Artifact Registry." - ) from e - except FileNotFoundError: - raise HelmError( - "gcloud CLI not found. Please install the Google Cloud SDK: https://cloud.google.com/sdk/docs/install" - ) from None - - -def resolve_chart( - oci_registry: OciRegistryConfig | None, - helm_repository_name: str | None, - use_latest_chart: bool, - chart_name: str = "agentex-agent", -) -> tuple[str, str]: - """Resolve the chart reference and version based on the deployment mode. - - For OCI mode, builds an oci:// reference and resolves version from: - --use-latest-chart (GAR only) > oci_registry.chart_version > default. - For classic mode, builds a repo/chart reference and uses default version. - - Returns: - (chart_reference, chart_version) - """ - if oci_registry: - chart_reference = f"oci://{oci_registry.url}/{chart_name}" - - if use_latest_chart: - if oci_registry.provider != "gar": - console.print( - "[yellow]โš [/yellow] --use-latest-chart only works with GAR provider (provider: gar), using default version" - ) - chart_version = DEFAULT_HELM_CHART_VERSION - else: - chart_version = get_latest_gar_chart_version(oci_registry.url) - elif oci_registry.chart_version: - chart_version = oci_registry.chart_version - else: - chart_version = DEFAULT_HELM_CHART_VERSION - else: - if not helm_repository_name: - raise HelmError("Helm repository name is required for classic mode") - chart_reference = f"{helm_repository_name}/{chart_name}" - - if use_latest_chart: - console.print("[yellow]โš [/yellow] --use-latest-chart only works with OCI registries, using default version") - chart_version = DEFAULT_HELM_CHART_VERSION - - console.print(f"[blue]โ„น[/blue] Using Helm chart version: {chart_version}") - return chart_reference, chart_version - - -def convert_env_vars_dict_to_list(env_vars: dict[str, str]) -> list[dict[str, str]]: - """Convert a dictionary of environment variables to a list of dictionaries""" - return [{"name": key, "value": value} for key, value in env_vars.items()] - - -def add_acp_command_to_helm_values(helm_values: dict[str, Any], manifest: AgentManifest, manifest_path: str) -> None: - """Add dynamic ACP command to helm values based on manifest configuration""" - try: - docker_acp_module = calculate_docker_acp_module(manifest, manifest_path) - # Create the uvicorn command with the correct module path - helm_values["command"] = ["uvicorn", f"{docker_acp_module}:acp", "--host", "0.0.0.0", "--port", "8000"] - logger.info(f"Using dynamic ACP command: uvicorn {docker_acp_module}:acp") - except (PathResolutionError, Exception) as e: - # Fallback to default command structure - logger.warning(f"Could not calculate dynamic ACP module ({e}), using default: project.acp") - helm_values["command"] = ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] - - -def merge_deployment_configs( - manifest: AgentManifest, - agent_env_config: AgentEnvironmentConfig | None, - deploy_overrides: InputDeployOverrides, - manifest_path: str, -) -> dict[str, Any]: - agent_config: AgentConfig = manifest.agent - - """Merge global deployment config with environment-specific overrides into helm values""" - if not manifest.deployment: - raise DeploymentError("No deployment configuration found in manifest") - - repository = deploy_overrides.repository or manifest.deployment.image.repository - image_tag = deploy_overrides.image_tag or manifest.deployment.image.tag - - if not repository or not image_tag: - raise DeploymentError("Repository and image tag are required") - - # Start with global configuration - helm_values: dict[str, Any] = { - "global": { - "image": { - "repository": repository, - "tag": image_tag, - "pullPolicy": "IfNotPresent", - }, - "agent": { - "name": manifest.agent.name, - "description": manifest.agent.description, - "acp_type": manifest.agent.acp_type, - }, - }, - "replicaCount": manifest.deployment.global_config.replicaCount, - "resources": { - "requests": { - "cpu": manifest.deployment.global_config.resources.requests.cpu, - "memory": manifest.deployment.global_config.resources.requests.memory, - }, - "limits": { - "cpu": manifest.deployment.global_config.resources.limits.cpu, - "memory": manifest.deployment.global_config.resources.limits.memory, - }, - }, - # Enable autoscaling by default for production deployments - "autoscaling": { - "enabled": True, - "minReplicas": 1, - "maxReplicas": 10, - "targetCPUUtilizationPercentage": 50, - }, - } - - # Handle temporal configuration using new helper methods - if agent_config.is_temporal_agent(): - temporal_config = agent_config.get_temporal_workflow_config() - if temporal_config: - helm_values[TEMPORAL_WORKER_KEY] = { - "enabled": True, - # Enable autoscaling for temporal workers as well - "autoscaling": { - "enabled": True, - "minReplicas": 1, - "maxReplicas": 10, - "targetCPUUtilizationPercentage": 50, - }, - } - helm_values["global"]["workflow"] = { - "name": temporal_config.name, - "taskQueue": temporal_config.queue_name, - } - - # Collect all environment variables with proper precedence - # Priority: manifest -> environments.yaml -> secrets (highest) - all_env_vars: dict[str, str] = {} - secret_env_vars: list[dict[str, str]] = [] - - # Start with agent_config env vars from manifest - if agent_config.env: - all_env_vars.update(agent_config.env) - - # Override with environment config env vars if they exist - if agent_env_config and agent_env_config.helm_overrides and "env" in agent_env_config.helm_overrides: - env_overrides = agent_env_config.helm_overrides["env"] - if isinstance(env_overrides, list): - # Convert list format to dict for easier merging - env_override_dict: dict[str, str] = {} - for env_var in env_overrides: - if isinstance(env_var, dict) and "name" in env_var and "value" in env_var: - env_override_dict[str(env_var["name"])] = str(env_var["value"]) - all_env_vars.update(env_override_dict) - - # Handle credentials and check for conflicts - if agent_config.credentials: - for credential in agent_config.credentials: - # Handle both CredentialMapping objects and legacy dict format - if isinstance(credential, dict): - env_var_name = credential["env_var_name"] - secret_name = credential["secret_name"] - secret_key = credential["secret_key"] - else: - env_var_name = credential.env_var_name - secret_name = credential.secret_name - secret_key = credential.secret_key - - # Check if the environment variable name conflicts with existing env vars - if env_var_name in all_env_vars: - logger.warning( - f"Environment variable '{env_var_name}' is defined in both " - f"env and secretEnvVars. The secret value will take precedence." - ) - # Remove from regular env vars since secret takes precedence - del all_env_vars[env_var_name] - - secret_env_vars.append( - { - "name": env_var_name, - "secretName": secret_name, - "secretKey": secret_key, - } - ) - - # Apply agent environment configuration overrides - if agent_env_config: - # Add auth principal env var if environment config is set - if agent_env_config.auth: - from agentex.lib.cli.utils.auth_utils import _encode_principal_context_from_env_config - - encoded_principal = _encode_principal_context_from_env_config(agent_env_config.auth) - logger.info(f"Encoding auth principal from {agent_env_config.auth}") - if encoded_principal: - all_env_vars[EnvVarKeys.AUTH_PRINCIPAL_B64.value] = encoded_principal - else: - raise DeploymentError(f"Auth principal unable to be encoded for agent_env_config: {agent_env_config}") - - logger.info(f"Defined agent helm overrides: {agent_env_config.helm_overrides}") - logger.info(f"Before-merge helm values: {helm_values}") - if agent_env_config.helm_overrides: - _deep_merge(helm_values, agent_env_config.helm_overrides) - logger.info(f"After-merge helm values: {helm_values}") - - # Set final environment variables - # Environment variable precedence: manifest -> environments.yaml -> secrets (highest) - if all_env_vars: - helm_values["env"] = convert_env_vars_dict_to_list(all_env_vars) - - if secret_env_vars: - helm_values["secretEnvVars"] = secret_env_vars - - # Set environment variables for temporal worker if enabled - if TEMPORAL_WORKER_KEY in helm_values: - if all_env_vars: - helm_values[TEMPORAL_WORKER_KEY]["env"] = convert_env_vars_dict_to_list(all_env_vars) - if secret_env_vars: - helm_values[TEMPORAL_WORKER_KEY]["secretEnvVars"] = secret_env_vars - - # Handle image pull secrets - if manifest.deployment and manifest.deployment.imagePullSecrets: - pull_secrets = [pull_secret.model_dump() for pull_secret in manifest.deployment.imagePullSecrets] - helm_values["global"]["imagePullSecrets"] = pull_secrets - helm_values["imagePullSecrets"] = pull_secrets - - # Add dynamic ACP command based on manifest configuration if command is not set in helm overrides - helm_overrides_command = ( - agent_env_config and agent_env_config.helm_overrides and "command" in agent_env_config.helm_overrides - ) - if not helm_overrides_command: - add_acp_command_to_helm_values(helm_values, manifest, manifest_path) - - logger.info("Deploying with the following helm values: %s", helm_values) - return helm_values - - -def _deep_merge(base_dict: dict[str, Any], override_dict: dict[str, Any]) -> None: - """Deep merge override_dict into base_dict""" - for key, value in override_dict.items(): - if key in base_dict and isinstance(base_dict[key], dict) and isinstance(value, dict): - _deep_merge(base_dict[key], value) - else: - base_dict[key] = value - - -def create_helm_values_file(helm_values: dict[str, Any]) -> str: - """Create a temporary helm values file""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - yaml.dump(helm_values, f, default_flow_style=False) - return f.name - - -def deploy_agent( - manifest_path: str, - cluster_name: str, - namespace: str, - deploy_overrides: InputDeployOverrides, - environment_name: str | None = None, - use_latest_chart: bool = False, -) -> None: - """Deploy an agent using helm - - Args: - manifest_path: Path to the agent manifest file - cluster_name: Target Kubernetes cluster name - namespace: Kubernetes namespace to deploy to - deploy_overrides: Image repository/tag overrides - environment_name: Environment name from environments.yaml - use_latest_chart: If True, fetch and use the latest chart version from OCI registry (OCI mode only) - """ - - # Validate prerequisites - if not check_helm_installed(): - raise DeploymentError("Helm is not installed. Please install helm first.") - - # Switch to the specified cluster context - check_and_switch_cluster_context(cluster_name) - - manifest = load_agent_manifest(file_path=manifest_path) - - # Load agent environment configuration - agent_env_config = None - if environment_name: - manifest_dir = Path(manifest_path).parent - environments_config = load_environments_config_from_manifest_dir(manifest_dir) - if environments_config: - agent_env_config = environments_config.get_config_for_env(environment_name) - console.print(f"[green]โœ“[/green] Using environment config: {environment_name}") - else: - console.print(f"[yellow]โš [/yellow] No environments.yaml found, skipping environment-specific config") - - # Determine deployment mode: OCI registry or classic helm repo - oci_registry = agent_env_config.oci_registry if agent_env_config else None - helm_repository_name: str | None = None - - if oci_registry: - console.print(f"[blue]โ„น[/blue] Using OCI Helm registry: {oci_registry.url}") - - # Only auto-authenticate for GAR provider - if oci_registry.provider == "gar": - login_to_gar_registry(oci_registry.url) - else: - console.print( - "[blue]โ„น[/blue] Skipping auto-authentication (no provider specified, assuming already authenticated)" - ) - else: - if agent_env_config: - helm_repository_name = agent_env_config.helm_repository_name - helm_repository_url = agent_env_config.helm_repository_url - else: - helm_repository_name = "scale-egp" - helm_repository_url = "https://scale-egp-helm-charts-us-west-2.s3.amazonaws.com/charts" - # Add helm repository/update (classic mode only) - add_helm_repo(helm_repository_name, helm_repository_url) - - # Resolve chart reference and version in one step - chart_reference, chart_version = resolve_chart( - oci_registry=oci_registry, - helm_repository_name=helm_repository_name, - use_latest_chart=use_latest_chart, - ) - - # Merge configurations - helm_values = merge_deployment_configs(manifest, agent_env_config, deploy_overrides, manifest_path) - - # Create values file - values_file = create_helm_values_file(helm_values) - - try: - agent_name = manifest.agent.name - release_name = agent_name - - console.print( - f"Deploying agent [bold]{agent_name}[/bold] to cluster [bold]{cluster_name}[/bold] in namespace [bold]{namespace}[/bold]" - ) - - # Check if release exists - try: - subprocess.run( - ["helm", "status", release_name, "-n", namespace], - capture_output=True, - check=True, - ) - - # Release exists, do upgrade - console.print("Existing deployment found, upgrading...") - command = [ - "helm", - "upgrade", - release_name, - chart_reference, - "--version", - chart_version, - "-f", - values_file, - "-n", - namespace, - "--atomic", - "--timeout", - "10m", - ] - console.print(f"[blue]โ„น[/blue] Running command: {' '.join(command)}") - subprocess.run(command, check=True) - console.print("[green]โœ“[/green] Agent upgraded successfully") - - except subprocess.CalledProcessError: - # Release doesn't exist, do install - console.print("Installing new deployment...") - command = [ - "helm", - "install", - release_name, - chart_reference, - "--version", - chart_version, - "-f", - values_file, - "-n", - namespace, - "--create-namespace", - "--atomic", - "--timeout", - "10m", - ] - console.print(f"[blue]โ„น[/blue] Running command: {' '.join(command)}") - subprocess.run(command, check=True) - console.print("[green]โœ“[/green] Agent deployed successfully") - - # Show success message with helpful commands - console.print("\n[green]๐ŸŽ‰ Deployment completed successfully![/green]") - console.print(f"[blue]Check deployment status:[/blue] helm status {release_name} -n {namespace}") - console.print(f"[blue]View logs:[/blue] kubectl logs -l app.kubernetes.io/name=agentex-agent -n {namespace}") - - except subprocess.CalledProcessError as e: - raise HelmError( - f"Helm deployment failed: {e}\n" - f"Note: Due to --atomic flag, any partial deployment has been automatically rolled back." - ) from e - finally: - # Clean up values file - os.unlink(values_file) diff --git a/src/agentex/lib/cli/handlers/run_handlers.py b/src/agentex/lib/cli/handlers/run_handlers.py deleted file mode 100644 index 3a43e95dd..000000000 --- a/src/agentex/lib/cli/handlers/run_handlers.py +++ /dev/null @@ -1,413 +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.config.agent_manifest import AgentManifest -from agentex.lib.cli.utils.path_utils import ( - get_file_paths, - calculate_uvicorn_target_for_local, -) -from agentex.lib.environment_variables import EnvVarKeys -from agentex.lib.sdk.config.agent_manifest import load_agent_manifest -from agentex.lib.cli.handlers.cleanup_handlers import cleanup_agent_workflows, should_cleanup_on_restart - -logger = make_logger(__name__) -console = Console() - - -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 = load_agent_manifest(file_path=manifest_path) - except Exception as e: - raise RunError(f"Failed to parse manifest: {str(e)}") from e - - # Get and validate file paths - try: - file_paths = get_file_paths(manifest, manifest_path) - except Exception as e: - raise RunError(str(e)) from e - - # Check if temporal agent and validate worker file - if is_temporal_agent(manifest): - if not file_paths["worker"]: - raise RunError("Temporal agent requires a worker file path to be configured") - - # Create environment for subprocesses - agent_env = create_agent_environment(manifest) - - # Setup process manager - process_manager = ProcessManager() - - try: - console.print( - Panel.fit( - f"๐Ÿš€ [bold blue]Running Agent: {manifest.agent.name}[/bold blue]", - border_style="blue", - ) - ) - - # Start ACP server (with debug support if enabled) - manifest_dir = Path(manifest_path).parent - if debug_config and debug_config.should_debug_acp(): - acp_process = await start_acp_server_debug( - file_paths["acp"], manifest.local_development.agent.port, agent_env, debug_config # type: ignore[union-attr] - ) - else: - acp_process = await start_acp_server( - file_paths["acp"], manifest.local_development.agent.port, agent_env, manifest_dir # type: ignore[union-attr] - ) - process_manager.add_process(acp_process) - - # Start output streaming for ACP - acp_output_task = asyncio.create_task(stream_process_output(acp_process, "ACP")) - - tasks = [acp_output_task] - - # Start temporal worker if needed (with debug support if enabled) - if is_temporal_agent(manifest) and file_paths["worker"]: - if debug_config and debug_config.should_debug_worker(): - # In debug mode, start worker without auto-reload to prevent conflicts - worker_process = await start_temporal_worker_debug( - file_paths["worker"], agent_env, debug_config - ) - process_manager.add_process(worker_process) - worker_task = asyncio.create_task(stream_process_output(worker_process, "WORKER")) - else: - # Normal mode with auto-reload - worker_task = await start_temporal_worker_with_reload(file_paths["worker"], agent_env, process_manager, manifest_dir) - tasks.append(worker_task) - - console.print( - f"\n[green]โœ“ Agent running at: http://localhost:{manifest.local_development.agent.port}[/green]" # type: ignore[union-attr] - ) - console.print("[dim]Press Ctrl+C to stop[/dim]\n") - - # Wait for shutdown signal or process failure - try: - await process_manager.wait_for_shutdown() - except KeyboardInterrupt: - console.print("\n[yellow]Received shutdown signal...[/yellow]") - - # Cancel output streaming tasks - for task in tasks: - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - except Exception as e: - logger.exception("Error running agent") - raise RunError(f"Failed to run agent: {str(e)}") from e - - finally: - # Ensure cleanup happens - await process_manager.cleanup_processes() - - - - - -def create_agent_environment(manifest: AgentManifest) -> dict[str, str]: - """Create environment variables for agent processes without modifying os.environ""" - # Start with current environment - env = dict(os.environ) - - agent_config = manifest.agent - - # TODO: Combine this logic with the deploy_handlers so that we can reuse the env vars - env_vars = { - "ENVIRONMENT": "development", - "TEMPORAL_ADDRESS": "localhost:7233", - "REDIS_URL": "redis://localhost:6379", - "AGENT_NAME": manifest.agent.name, - "ACP_TYPE": manifest.agent.acp_type, - "ACP_URL": f"http://{manifest.local_development.agent.host_address}", # type: ignore[union-attr] - "ACP_PORT": str(manifest.local_development.agent.port), # type: ignore[union-attr] - } - - if manifest.agent.agent_input_type: - env_vars["AGENT_INPUT_TYPE"] = manifest.agent.agent_input_type - - # Add authorization principal if set - for local development, auth is optional - from agentex.lib.cli.utils.auth_utils import _encode_principal_context - encoded_principal = _encode_principal_context(manifest) - if encoded_principal: - env_vars[EnvVarKeys.AUTH_PRINCIPAL_B64] = encoded_principal - else: - logger.info("No auth principal configured - agent will run without authentication context") - - # Add description if available - if manifest.agent.description: - env_vars["AGENT_DESCRIPTION"] = manifest.agent.description - - # Add temporal-specific variables if this is a temporal agent - if manifest.agent.is_temporal_agent(): - temporal_config = manifest.agent.get_temporal_workflow_config() - if temporal_config: - env_vars["WORKFLOW_NAME"] = temporal_config.name - env_vars["WORKFLOW_TASK_QUEUE"] = temporal_config.queue_name - - # Set health check port from temporal config - if manifest.agent.temporal and manifest.agent.temporal.health_check_port is not None: - env_vars["HEALTH_CHECK_PORT"] = str(manifest.agent.temporal.health_check_port) - - if agent_config.env: - for key, value in agent_config.env.items(): - env_vars[key] = value - - env.update(env_vars) - - return env - - -def is_temporal_agent(manifest: AgentManifest) -> bool: - """Check if this is a temporal agent""" - return manifest.agent.is_temporal_agent() diff --git a/src/agentex/lib/cli/handlers/secret_handlers.py b/src/agentex/lib/cli/handlers/secret_handlers.py deleted file mode 100644 index 485efa998..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.config.credentials import CredentialMapping -from agentex.config.agent_config import AgentConfig -from agentex.config.agent_manifest import AgentManifest -from agentex.lib.cli.utils.cli_utils import handle_questionary_cancellation -from agentex.config.deployment_config import ( - DeploymentConfig, - ImagePullSecretConfig, - InjectedSecretsValues, -) -from agentex.lib.cli.utils.kubectl_utils import get_k8s_client -from agentex.lib.cli.utils.kubernetes_secrets_utils import ( - VALID_SECRET_TYPES, - KUBERNETES_SECRET_TYPE_OPAQUE, - KUBERNETES_SECRET_TO_MANIFEST_KEY, - KUBERNETES_SECRET_TYPE_DOCKERCONFIGJSON, - get_secret_data, - create_secret_with_data, - update_secret_with_data, - create_image_pull_secret_with_data, - update_image_pull_secret_with_data, -) - -logger = make_logger(__name__) -console = Console() - - -# TODO: parse this into a Pydantic model. -def load_values_file(values_path: str) -> dict[str, dict[str, str]]: - """Load and parse the values file (YAML/JSON)""" - try: - path = Path(values_path) - content = path.read_text() - - if path.suffix.lower() in [".yaml", ".yml"]: - data = yaml.safe_load(content) - elif path.suffix.lower() == ".json": - data = json.loads(content) - else: - # Try YAML first, then JSON - try: - data = yaml.safe_load(content) - except yaml.YAMLError: - data = json.loads(content) - return InjectedSecretsValues.model_validate(data).model_dump() - - except Exception as e: - raise RuntimeError( - f"Failed to load values file '{values_path}': {str(e)}" - ) from e - - -def interactive_secret_input(secret_name: str, secret_key: str) -> str: - """Prompt user for secret value with appropriate input method""" - console.print( - f"\n[bold]Enter value for secret '[cyan]{secret_name}[/cyan]' key '[cyan]{secret_key}[/cyan]':[/bold]" - ) - - input_type = questionary.select( - "What type of value is this?", - choices=[ - "Simple text", - "Sensitive/password (hidden input)", - "Multi-line text", - "JSON/YAML content", - "Read from file", - ], - ).ask() - - input_type = handle_questionary_cancellation(input_type, "secret input") - - if input_type == "Sensitive/password (hidden input)": - result = questionary.password("Enter value (input will be hidden):").ask() - return handle_questionary_cancellation(result, "password input") - - elif input_type == "Multi-line text": - console.print( - "[yellow]Enter multi-line text (press Ctrl+D when finished):[/yellow]" - ) - lines = [] - try: - while True: - line = input() - lines.append(line) - except EOFError: - pass - except KeyboardInterrupt: - console.print("[yellow]Multi-line input cancelled by user[/yellow]") - raise typer.Exit(0) # noqa - return "\n".join(lines) - - elif input_type == "JSON/YAML content": - value = questionary.text("Enter JSON/YAML content:").ask() - value = handle_questionary_cancellation(value, "JSON/YAML input") - # Validate JSON/YAML format - try: - json.loads(value) - except json.JSONDecodeError: - try: - yaml.safe_load(value) - except yaml.YAMLError: - console.print( - "[yellow]Warning: Content doesn't appear to be valid JSON or YAML[/yellow]" - ) - return value - - elif input_type == "Read from file": - file_path = questionary.path("Enter file path:").ask() - file_path = handle_questionary_cancellation(file_path, "file path input") - try: - return Path(file_path).read_text().strip() - except Exception as e: - console.print(f"[red]Error reading file: {e}[/red]") - manual_value = questionary.text("Enter value manually:").ask() - return handle_questionary_cancellation(manual_value, "manual value input") - - else: # Simple text - result = questionary.text("Enter value:").ask() - return handle_questionary_cancellation(result, "text input") - - -def get_secret(name: str, namespace: str, context: str | None = None) -> dict[str, Any]: - """Get details about a secret""" - v1 = get_k8s_client(context) - - try: - secret = v1.read_namespaced_secret(name=name, namespace=namespace) - return { - "name": secret.metadata.name, # type: ignore[union-attr] - "namespace": namespace, - "created": secret.metadata.creation_timestamp.isoformat(), # type: ignore[union-attr] - "exists": True, - } - except ApiException as e: - if e.status == 404: - console.print( - f"[red]Error: Secret '{name}' not found in namespace '{namespace}'[/red]" - ) - return {"name": name, "namespace": namespace, "exists": False} - raise RuntimeError(f"Failed to get secret: {str(e)}") from e - - -def delete_secret(name: str, namespace: str, context: str | None = None) -> None: - """Delete a secret""" - v1 = get_k8s_client(context) - - try: - v1.delete_namespaced_secret(name=name, namespace=namespace) - console.print( - f"[green]Deleted secret '{name}' from namespace '{namespace}'[/green]" - ) - except ApiException as e: - if e.status == 404: - console.print( - f"[red]Error: Secret '{name}' not found in namespace '{namespace}'[/red]" - ) - else: - console.print(f"[red]Error deleting secret: {e.reason}[/red]") - raise RuntimeError(f"Failed to delete secret: {str(e)}") from e - - -def get_kubernetes_secrets_by_type( - namespace: str, context: str | None = None -) -> dict[str, list[dict[str, Any]]]: - """List metadata about secrets in the namespace""" - v1 = get_k8s_client(context) - - try: - secrets = v1.list_namespaced_secret(namespace=namespace) - secret_type_to_secret = defaultdict(list) - for secret in secrets.items: - if secret.type in VALID_SECRET_TYPES: - secret_type_to_secret[secret.type].append( - { - "name": secret.metadata.name, - "namespace": namespace, - "created": secret.metadata.creation_timestamp.isoformat(), - } - ) - - return secret_type_to_secret - except ApiException as e: - console.print( - f"[red]Error listing secrets in namespace '{namespace}': {e.reason}[/red]" - ) - raise RuntimeError(f"Failed to list secrets: {str(e)}") from e - - # NOTE: This corresponds with KUBERNETES_SECRET_TYPE_OPAQUE - - -def sync_user_defined_secrets( - manifest_obj: AgentManifest, - found_secrets: list[dict], - values_data: dict[str, Any], - cluster: str, - namespace: str, - interactive: bool, - changes: dict[str, list[str]], -) -> None: - """Sync user defined secrets between manifest, cluster, and values file""" - console.print( - f"[bold]Syncing user defined secrets to cluster: {cluster} namespace: {namespace}[/bold]" - ) - - # Get the secrets from the cluster using the specified namespace and cluster context - cluster_secret_names = {secret["name"] for secret in found_secrets} - # Get the secrets from the manifest - agent_config: AgentConfig = manifest_obj.agent - manifest_credentials: list[CredentialMapping] = agent_config.credentials or [] # type: ignore[assignment] - - if not manifest_credentials: - console.print("[yellow]No credentials found in manifest[/yellow]") - return - - # Build required secrets map from manifest - required_secrets = {} # {secret_name: {secret_key: env_var_name}} - for cred in manifest_credentials: - if cred.secret_name not in required_secrets: - required_secrets[cred.secret_name] = {} - required_secrets[cred.secret_name][cred.secret_key] = cred.env_var_name - - # Process each required secret - for secret_name, required_keys in required_secrets.items(): - current_secret_data = get_secret_data(secret_name, namespace, cluster) - new_secret_data = {} - secret_needs_update = False - - # Process each required key in this secret - for secret_key, _ in required_keys.items(): - current_value = current_secret_data.get(secret_key) - - # Get the new value - if ( - values_data - and secret_name in values_data - and secret_key in values_data[secret_name] - ): - new_value = values_data[secret_name][secret_key] - elif interactive: - if current_value: - console.print( - f"[blue]Secret '{secret_name}' key '{secret_key}' already exists[/blue]" - ) - update_choice = questionary.select( - "What would you like to do?", - choices=[ - "Keep current value", - "Update with new value", - "Show current value", - ], - ).ask() - update_choice = handle_questionary_cancellation( - update_choice, "secret update choice" - ) - - if update_choice == "Show current value": - console.print(f"Current value: [dim]{current_value}[/dim]") - update_choice = questionary.select( - "What would you like to do?", - choices=["Keep current value", "Update with new value"], - ).ask() - update_choice = handle_questionary_cancellation( - update_choice, "secret update choice" - ) - - if update_choice == "Update with new value": - new_value = interactive_secret_input(secret_name, secret_key) - else: - new_value = current_value - else: - console.print( - f"[yellow]Secret '{secret_name}' key '{secret_key}' does not exist[/yellow]" - ) - new_value = interactive_secret_input(secret_name, secret_key) - else: - raise RuntimeError( - f"No value provided for secret '{secret_name}' key '{secret_key}'. Provide values file or use interactive mode." - ) - - # Must be a string because kubernetes always expects a - new_value = str(new_value) - new_secret_data[secret_key] = new_value - - # Check if value changed - if current_value != new_value: - secret_needs_update = True - else: - changes["noop"].append( - f"Secret '{secret_name}' key '{secret_key}' is up to date" - ) - - # Determine action needed - if secret_name not in cluster_secret_names: - changes["create"].append( - f"Create secret '{secret_name}' with keys: {list(required_keys.keys())}" - ) - create_secret_with_data(secret_name, new_secret_data, namespace, cluster) - elif secret_needs_update: - changes["update"].append(f"Update secret '{secret_name}' (values changed)") - update_secret_with_data(secret_name, new_secret_data, namespace, cluster) - - # Handle orphaned secrets (in cluster but not in manifest) - orphaned_secrets = cluster_secret_names - set(required_secrets.keys()) - if orphaned_secrets: - console.print( - f"\n[yellow]Warning: Found {len(orphaned_secrets)} secrets in cluster not defined in manifest:[/yellow]" - ) - for secret in orphaned_secrets: - console.print(f" - {secret}") - - -def create_dockerconfigjson_string( - registry: str, username: str, password: str, email: str | None = None -) -> str: - """Create raw dockerconfigjson string data for use with Kubernetes string_data field""" - # Create the auth field (base64 encoded username:password) - auth_string = f"{username}:{password}" - auth_b64 = base64.b64encode(auth_string.encode("utf-8")).decode("utf-8") - - # Build the auth entry - auth_entry = {"username": username, "password": password, "auth": auth_b64} - - # Only include email if provided - if email: - auth_entry["email"] = email - - # Create the full dockerconfig structure - docker_config = {"auths": {registry: auth_entry}} - - # Return raw JSON string (Kubernetes will handle base64 encoding when using string_data) - return json.dumps(docker_config) - - -def parse_dockerconfigjson_data(input_data: str) -> dict[str, dict[str, str]]: - """Parse existing dockerconfigjson data to extract registry credentials""" - try: - # Decode base64 - config = json.loads(input_data) - - # Extract auths section - auths = config.get("auths", {}) - - # Convert to comparable format: {registry: {username, password, email}} - parsed_auths = {} - for registry, auth_data in auths.items(): - # Try to decode the base64 auth field first - username = "" - password = "" - if "auth" in auth_data: - try: - auth_b64 = auth_data["auth"] - username_password = base64.b64decode(auth_b64).decode("utf-8") - if ":" in username_password: - username, password = username_password.split(":", 1) - except Exception: - pass - - # Fall back to direct username/password fields if auth decode failed - if not username: - username = auth_data.get("username", "") - if not password: - password = auth_data.get("password", "") - - parsed_auths[registry] = { - "username": username, - "password": password, - "email": auth_data.get("email", ""), - } - - return parsed_auths - except Exception: - return {} # If parsing fails, assume empty/invalid - - -def credentials_changed( - current_auths: dict[str, dict[str, str]], - new_registry: str, - new_username: str, - new_password: str, - new_email: str = "", -) -> bool: - """Check if credentials have actually changed""" - - # If registry doesn't exist in current, it's a change - if new_registry not in current_auths: - return True - - current_creds = current_auths[new_registry] - # Compare each field - if ( - current_creds.get("username", "") != new_username - or current_creds.get("password", "") != new_password - or current_creds.get("email", "") != (new_email or "") - ): - return True - else: - return False # No changes detected - - -def interactive_image_pull_secret_input(secret_name: str) -> dict[str, str]: - """Prompt user for image pull secret values""" - console.print( - f"\n[bold]Configure image pull secret '[cyan]{secret_name}[/cyan]':[/bold]" - ) - - registry = questionary.text( - "Registry URL (e.g., docker.io, gcr.io, your-registry.com):", - default="docker.io", - ).ask() - registry = handle_questionary_cancellation(registry, "registry input") - - username = questionary.text("Username:").ask() - username = handle_questionary_cancellation(username, "username input") - - password = questionary.password("Password (input will be hidden):").ask() - password = handle_questionary_cancellation(password, "password input") - - email_choice = questionary.confirm( - "Do you want to include an email address? (optional)" - ).ask() - email_choice = handle_questionary_cancellation(email_choice, "email choice") - email = "" - if email_choice: - email = questionary.text("Email address:").ask() or "" - if email is None: # Handle None from questionary - email = "" - - return { - "registry": registry, - "username": username, - "password": password, - "email": email, - } - - -def sync_image_pull_secrets( - manifest_obj: AgentManifest, - found_dockerconfigjson_secrets: list[dict], - values_data: dict[str, Any], - cluster: str, - namespace: str, - interactive: bool, - changes: dict[str, list[str]], -) -> None: - """Sync image pull secrets between manifest, cluster, and values file""" - console.print( - f"[bold]Syncing image pull secrets to cluster: {cluster} namespace: {namespace}[/bold]" - ) - - # Get the secrets of type KUBERNETES_SECRET_TYPE_DOCKERCONFIGJSON - cluster_dockerconfigjson_secret_names = { - secret["name"] for secret in found_dockerconfigjson_secrets - } - - # Get the secrets from the manifest - deployment_config: DeploymentConfig = manifest_obj.deployment # type: ignore[assignment] - manifest_image_pull_secrets: list[ImagePullSecretConfig] = ( - deployment_config.imagePullSecrets or [] - ) - - if not manifest_image_pull_secrets: - logger.info("No image pull secrets found in manifest") - return - - # Get image pull secrets from values data - image_pull_values = values_data - - # Process each required image pull secret - for pull_secret in manifest_image_pull_secrets: - secret_name = pull_secret.name - current_secret_data = get_secret_data(secret_name, namespace, cluster) - - # Get new values - new_registry = "" - new_username = "" - new_password = "" - new_email = "" - - if secret_name in image_pull_values: - # Get values from values file - secret_config = image_pull_values[secret_name] - new_registry = secret_config.get("registry", "") - new_username = secret_config.get("username", "") - new_password = secret_config.get("password", "") - new_email = secret_config.get("email", "") - - if not new_registry or not new_username or not new_password: - raise RuntimeError( - f"Incomplete image pull secret configuration for '{secret_name}'. " - f"Required: registry, username, password. Optional: email" - ) - elif interactive: - # Get values interactively - if secret_name in cluster_dockerconfigjson_secret_names: - console.print( - f"[blue]Image pull secret '{secret_name}' already exists[/blue]" - ) - update_choice = questionary.select( - "What would you like to do?", - choices=["Keep current credentials", "Update with new credentials"], - ).ask() - update_choice = handle_questionary_cancellation( - update_choice, "image pull secret update choice" - ) - - if update_choice == "Keep current credentials": - continue # Skip this secret - - console.print( - f"[yellow]Image pull secret '{secret_name}' needs configuration[/yellow]" - ) - creds = interactive_image_pull_secret_input(secret_name) - new_registry = creds["registry"] - new_username = creds["username"] - new_password = creds["password"] - new_email = creds["email"] - else: - raise RuntimeError( - f"No configuration provided for image pull secret '{secret_name}'. " - f"Provide values file or use interactive mode." - ) - - # Check if update is needed - secret_needs_update = False - action = "" - - if secret_name not in cluster_dockerconfigjson_secret_names: - # Secret doesn't exist, needs creation - secret_needs_update = True - action = "create" - else: - # Secret exists, check if values changed - current_dockerconfig = current_secret_data.get(".dockerconfigjson", {}) - current_auths = parse_dockerconfigjson_data(current_dockerconfig) - if credentials_changed( - current_auths, new_registry, new_username, new_password, new_email - ): - secret_needs_update = True - action = "update" - else: - changes["noop"].append( - f"Secret '{secret_name}' key '{secret_name}' is up to date" - ) - - # Only perform action if update is needed - if secret_needs_update: - dockerconfig_string = create_dockerconfigjson_string( - new_registry, new_username, new_password, new_email - ) - secret_data = {".dockerconfigjson": dockerconfig_string} - - if action == "create": - changes[action].append( - f"Create image pull secret '{secret_name}' for registry '{new_registry}'" - ) - create_image_pull_secret_with_data( - secret_name, secret_data, namespace, cluster - ) - elif action == "update": - changes[action].append( - f"Update image pull secret '{secret_name}' (credentials changed)" - ) - update_image_pull_secret_with_data( - secret_name, secret_data, namespace, cluster - ) - - -def print_changes_summary(change_type: str, changes: dict[str, list[str]]) -> None: - # Show summary - console.print(f"\n[bold]Sync Summary for {change_type}:[/bold]") - if changes["create"]: - console.print("[green]Created:[/green]") - for change in changes["create"]: - console.print(f" โœ“ {change}") - - if changes["update"]: - console.print("[yellow]Updated:[/yellow]") - for change in changes["update"]: - console.print(f" โš  {change}") - - if changes["noop"]: - console.print("[yellow]No changes:[/yellow]") - for change in changes["noop"]: - console.print(f" โœ“ {change}") - del changes["noop"] - - if not any(changes.values()): - console.print( - f"[green]โœ“ All secrets are already in sync for {change_type}[/green]" - ) - - console.print("") - - -def sync_secrets( - manifest_obj: AgentManifest, - cluster: str, - namespace: str, - interactive: bool, - values_path: str | None, -) -> None: - """Sync secrets between manifest, cluster, and values file""" - logger.info(f"Syncing secrets to cluster: {cluster} namespace: {namespace}") - - # Load values from file if provided - values_data = {} - if values_path: - try: - # TODO: Convert this to a pydantic model to validate the values file - values_data = load_values_file(values_path) - console.print(f"[green]Loaded values from {values_path}[/green]") - except Exception as e: - console.print(f"[red]Error loading values file: {e}[/red]") - raise - - # Get the secrets from the cluster using the specified namespace and cluster context - cluster_secrets_by_type = get_kubernetes_secrets_by_type( - namespace=namespace, context=cluster - ) - - # Track changes for summary - changes = {"create": [], "update": [], "noop": []} - - sync_user_defined_secrets( - manifest_obj, - cluster_secrets_by_type[KUBERNETES_SECRET_TYPE_OPAQUE], - values_data.get( - KUBERNETES_SECRET_TO_MANIFEST_KEY[KUBERNETES_SECRET_TYPE_OPAQUE], {} - ), - cluster, - namespace, - interactive, - changes, - ) - - print_changes_summary("User Defined Secrets", changes) - - # Track changes for summary - changes = {"create": [], "update": [], "noop": []} - - sync_image_pull_secrets( - manifest_obj, - cluster_secrets_by_type[KUBERNETES_SECRET_TYPE_DOCKERCONFIGJSON], - values_data.get( - KUBERNETES_SECRET_TO_MANIFEST_KEY[KUBERNETES_SECRET_TYPE_DOCKERCONFIGJSON], - {}, - ), - cluster, - namespace, - interactive, - changes, - ) - - print_changes_summary("Image Pull Secrets", changes) - - console.print( - f"\n[green]Secret sync completed for cluster '{cluster}' namespace '{namespace}'[/green]" - ) diff --git a/src/agentex/lib/cli/templates/default-claude-code/.dockerignore.j2 b/src/agentex/lib/cli/templates/default-claude-code/.dockerignore.j2 deleted file mode 100644 index c2d7fca4d..000000000 --- a/src/agentex/lib/cli/templates/default-claude-code/.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-claude-code/.env.example.j2 b/src/agentex/lib/cli/templates/default-claude-code/.env.example.j2 deleted file mode 100644 index 5aff34a60..000000000 --- a/src/agentex/lib/cli/templates/default-claude-code/.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 the Claude Code CLI (the `claude` subprocess this agent spawns) -ANTHROPIC_API_KEY= - -# LLM base URL (optional - override to use a different provider) -# OPENAI_BASE_URL= - -# SGP Configuration (optional - for tracing) -# SGP_API_KEY= -# SGP_ACCOUNT_ID= -# SGP_CLIENT_BASE_URL= diff --git a/src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 deleted file mode 100644 index 93d0f82d1..000000000 --- a/src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 +++ /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 \ - nodejs \ - npm \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/** - -# Install the Claude Code CLI: the agent shells out to `claude` on every turn, -# so the binary must be present in the runtime image. -RUN npm install -g @anthropic-ai/claude-code - -ENV UV_COMPILE_BYTECODE=1 -ENV UV_LINK_MODE=copy -ENV UV_HTTP_TIMEOUT=1000 - -WORKDIR /app/{{ project_path_from_build_root }} - -# Copy dependency files for layer caching -COPY {{ project_path_from_build_root }}/pyproject.toml ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-install-project --no-dev - -# Copy the project code -COPY {{ project_path_from_build_root }}/project ./project - -# Install the project -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-dev - -ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" -ENV PYTHONPATH=/app - -# Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 deleted file mode 100644 index d714d96f9..000000000 --- a/src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 +++ /dev/null @@ -1,46 +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 the Claude Code CLI: the agent shells out to `claude` on every turn, -# so the binary must be present in the runtime image. -RUN npm install -g @anthropic-ai/claude-code - -RUN uv pip install --system --upgrade pip setuptools wheel - -ENV UV_HTTP_TIMEOUT=1000 - -# Copy just the requirements file to optimize caching -COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt - -WORKDIR /app/{{ project_path_from_build_root }} - -# Install the required Python packages -RUN uv pip install --system -r requirements.txt - -# Copy the project code -COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project - -# Set environment variables -ENV PYTHONPATH=/app - -# Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-claude-code/README.md.j2 b/src/agentex/lib/cli/templates/default-claude-code/README.md.j2 deleted file mode 100644 index ab05398e3..000000000 --- a/src/agentex/lib/cli/templates/default-claude-code/README.md.j2 +++ /dev/null @@ -1,64 +0,0 @@ -# {{ agent_name }} - AgentEx Async Claude Code Agent - -This template builds an **asynchronous** (non-Temporal) agent that drives the -**Claude Code CLI** through the unified harness surface on AgentEx: -- Spawns `claude -p --output-format stream-json --verbose` as a local subprocess -- Wraps the CLI's stdout stream in a `ClaudeCodeTurn` -- Delivers canonical `StreamTaskMessage*` events via `UnifiedEmitter.auto_send_turn` - (the async Redis push path), so the UI receives output in real time -- Tracing integration to SGP / AgentEx - -## Prerequisites - -- The `claude` CLI installed and on your `PATH` -- An `ANTHROPIC_API_KEY` (or equivalent credential) in your environment - -## Running the Agent - -```bash -agentex agents run --manifest manifest.yaml -``` - -## Project Structure - -``` -{{ project_name }}/ -โ”œโ”€โ”€ project/ -โ”‚ โ”œโ”€โ”€ __init__.py -โ”‚ โ””โ”€โ”€ acp.py # ACP server, subprocess spawn, and event handlers -โ”œโ”€โ”€ Dockerfile -โ”œโ”€โ”€ manifest.yaml -โ”œโ”€โ”€ dev.ipynb -{% if use_uv %} -โ””โ”€โ”€ pyproject.toml -{% else %} -โ””โ”€โ”€ requirements.txt -{% endif %} -``` - -## Key Concepts - -### Async ACP with the harness -The async ACP model streams events over Redis instead of an HTTP response. The -`@acp.on_task_event_send` handler spawns the Claude Code CLI and pushes the -harness events to the task stream. - -### The unified harness surface -`ClaudeCodeTurn` + `UnifiedEmitter` are the unified harness surface. The turn -normalizes CLI output into canonical AgentEx events; the emitter traces and -delivers them. - -## Development - -### 1. Customize the subprocess -Edit `_spawn_claude` in `project/acp.py` to change the CLI flags, working -directory, or how the prompt is delivered. - -### 2. Configure Credentials -Set your credentials via `manifest.yaml`, an exported environment variable, or a -`.env` file in the project directory. - -### 3. Run Locally -```bash -export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml -``` diff --git a/src/agentex/lib/cli/templates/default-claude-code/dev.ipynb.j2 b/src/agentex/lib/cli/templates/default-claude-code/dev.ipynb.j2 deleted file mode 100644 index d3a68303f..000000000 --- a/src/agentex/lib/cli/templates/default-claude-code/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-claude-code/environments.yaml.j2 b/src/agentex/lib/cli/templates/default-claude-code/environments.yaml.j2 deleted file mode 100644 index f802776f0..000000000 --- a/src/agentex/lib/cli/templates/default-claude-code/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-claude-code/manifest.yaml.j2 b/src/agentex/lib/cli/templates/default-claude-code/manifest.yaml.j2 deleted file mode 100644 index ee08bc91b..000000000 --- a/src/agentex/lib/cli/templates/default-claude-code/manifest.yaml.j2 +++ /dev/null @@ -1,123 +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 | tojson }} - - # Temporal workflow configuration - # Set enabled: true to use Temporal workflows for long-running tasks - temporal: - enabled: false - - # Optional: Credentials mapping - # Maps Kubernetes secrets to environment variables - # Common credentials include: - credentials: - # The Claude Code CLI authenticates with ANTHROPIC_API_KEY (LITELLM_API_KEY - # is not read by the `claude` subprocess this agent spawns). - - env_var_name: ANTHROPIC_API_KEY - secret_name: anthropic-api-key - secret_key: api-key - - env_var_name: SGP_API_KEY - secret_name: sgp-api-key - secret_key: api-key - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - - # Optional: Set Environment variables for running your agent locally as well - # as for deployment later on. ANTHROPIC_API_KEY is supplied via the credential - # mapping above (deploy) or your local .env (load_dotenv). Do NOT set it to an - # empty string here โ€” that would shadow the real key at runtime. - env: {} - # ANTHROPIC_API_KEY: "" # uncomment only to hardcode for local runs - -# Deployment Configuration -# ----------------------- -# Configuration for deploying your agent to Kubernetes clusters -deployment: - # Container image configuration - image: - repository: "" # Update with your container registry - tag: "latest" # Default tag, should be versioned in production - - imagePullSecrets: [] # Update with your image pull secret names - # - name: my-registry-secret - - # Global deployment settings that apply to all clusters - # These can be overridden in cluster-specific environments (environments.yaml) - global: - # Default replica count - replicaCount: 1 - - # Default resource requirements - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-claude-code/project/acp.py.j2 b/src/agentex/lib/cli/templates/default-claude-code/project/acp.py.j2 deleted file mode 100644 index 42512c601..000000000 --- a/src/agentex/lib/cli/templates/default-claude-code/project/acp.py.j2 +++ /dev/null @@ -1,167 +0,0 @@ -"""ACP handler for {{ agent_name }} โ€” an async Claude Code agent. - -Spawns ``claude -p --output-format stream-json --verbose`` as a LOCAL -asyncio subprocess (no Scale sandbox โ€” that is a production concern). Stdout -lines are fed into ``ClaudeCodeTurn``. Events are delivered via -``UnifiedEmitter.auto_send_turn``, the async Redis push path. - -Live runs require the ``claude`` CLI to be installed and an -ANTHROPIC_API_KEY (or equivalent credential) in the environment. -""" - -from __future__ import annotations - -import os -import asyncio -from typing import AsyncIterator -from collections import deque - -from dotenv import load_dotenv - -load_dotenv() - -import agentex.lib.adk as adk -from agentex.lib.adk import ClaudeCodeTurn -from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams -from agentex.lib.core.harness import UnifiedEmitter -from agentex.lib.types.fastacp import AsyncACPConfig -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.types.text_content import TextContent -from agentex.lib.sdk.fastacp.fastacp import FastACP -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config - -logger = make_logger(__name__) - -add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SGP_API_KEY", ""), - sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - ) -) - -acp = FastACP.create( - acp_type="async", - config=AsyncACPConfig(type="base"), -) - - -async def _spawn_claude(prompt: str) -> AsyncIterator[str]: - """Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines. - - Injectable seam: tests can monkeypatch this with a fake async iterator of - pre-recorded lines so no real CLI invocation is needed offline. - """ - proc = await asyncio.create_subprocess_exec( - "claude", - "-p", - "--output-format", - "stream-json", - "--verbose", - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - assert proc.stdout is not None - assert proc.stdin is not None - - proc.stdin.write(prompt.encode()) - await proc.stdin.drain() - proc.stdin.close() - - # Drain stderr concurrently. With --verbose, Claude Code can write enough to - # stderr to fill the OS pipe buffer; if we only read stdout, the CLI blocks - # on its stderr write while we block reading stdout โ€” a deadlock. A - # background task keeps stderr flowing so stdout never stalls. We keep a - # bounded tail so a non-zero exit can be surfaced with context instead of - # silently completing the turn. - stderr_tail: deque[str] = deque(maxlen=20) - - async def _drain_stderr() -> None: - assert proc.stderr is not None - async for raw in proc.stderr: - text = raw.decode("utf-8", errors="replace").rstrip() - if text: - stderr_tail.append(text) - - stderr_task = asyncio.create_task(_drain_stderr()) - - try: - buffer = "" - async for chunk in proc.stdout: - buffer += chunk.decode("utf-8", errors="replace") - while "\n" in buffer: - line, buffer = buffer.split("\n", 1) - line = line.strip() - if line: - yield line - - if buffer.strip(): - yield buffer.strip() - - await proc.wait() - if proc.returncode: - # The CLI failed (missing binary/auth, bad command). Raise so the - # turn surfaces as failed instead of completing with no output. - tail = "\n".join(stderr_tail) - raise RuntimeError( - f"claude CLI exited with status {proc.returncode}:\n{tail}" - ) - finally: - # Release the subprocess and stderr drain task even if the consumer - # abandons the generator early (task cancellation / client disconnect): - # cancel the drain task and terminate+reap the process if it is still - # running, so neither is leaked. - stderr_task.cancel() - try: - await stderr_task - except asyncio.CancelledError: - pass - if proc.returncode is None: - try: - proc.terminate() - except ProcessLookupError: - pass - await proc.wait() - - -@acp.on_task_create -async def handle_task_create(params: CreateTaskParams): - logger.info("Task created: %s", params.task.id) - - -@acp.on_task_event_send -async def handle_task_event_send(params: SendEventParams): - """Handle a user message: spawn Claude Code locally and push events to the task stream.""" - task_id = params.task.id - content = params.event.content - if not isinstance(content, TextContent): - logger.warning("Ignoring non-text event content (type=%s)", getattr(content, "type", "?")) - return - prompt = content.content - logger.info("Processing message for task %s", task_id) - - await adk.messages.create(task_id=task_id, content=params.event.content) - - async with adk.tracing.span( - trace_id=task_id, - task_id=task_id, - name="message", - input={"message": prompt}, - data={"__span_type__": "AGENT_WORKFLOW"}, - ) as turn_span: - emitter = UnifiedEmitter( - task_id=task_id, - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - ) - turn = ClaudeCodeTurn(_spawn_claude(prompt)) - result = await emitter.auto_send_turn(turn) - if turn_span: - turn_span.output = {"final_text": result.final_text} - - -@acp.on_task_cancel -async def handle_task_canceled(params: CancelTaskParams): - logger.info("Task canceled: %s", params.task.id) diff --git a/src/agentex/lib/cli/templates/default-claude-code/pyproject.toml.j2 b/src/agentex/lib/cli/templates/default-claude-code/pyproject.toml.j2 deleted file mode 100644 index e499b1dc1..000000000 --- a/src/agentex/lib/cli/templates/default-claude-code/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", - "python-dotenv>=1.0,<2", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "black", - "isort", - "flake8", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] - -[tool.black] -line-length = 88 -target-version = ['py312'] - -[tool.isort] -profile = "black" -line_length = 88 diff --git a/src/agentex/lib/cli/templates/default-claude-code/requirements.txt.j2 b/src/agentex/lib/cli/templates/default-claude-code/requirements.txt.j2 deleted file mode 100644 index 8c0630384..000000000 --- a/src/agentex/lib/cli/templates/default-claude-code/requirements.txt.j2 +++ /dev/null @@ -1,8 +0,0 @@ -# Install agentex-sdk from local path -agentex-sdk - -# Scale GenAI Platform Python SDK -scale-gp - -# Loads .env files for local development -python-dotenv>=1.0,<2 diff --git a/src/agentex/lib/cli/templates/default-codex/.dockerignore.j2 b/src/agentex/lib/cli/templates/default-codex/.dockerignore.j2 deleted file mode 100644 index c2d7fca4d..000000000 --- a/src/agentex/lib/cli/templates/default-codex/.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-codex/.env.example.j2 b/src/agentex/lib/cli/templates/default-codex/.env.example.j2 deleted file mode 100644 index 5d621a83e..000000000 --- a/src/agentex/lib/cli/templates/default-codex/.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 used by the codex CLI (`codex exec` reads OPENAI_API_KEY directly) -OPENAI_API_KEY= - -# LLM base URL (optional - override to use a different provider) -# OPENAI_BASE_URL= - -# SGP Configuration (optional - for tracing) -# SGP_API_KEY= -# SGP_ACCOUNT_ID= -# SGP_CLIENT_BASE_URL= diff --git a/src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 deleted file mode 100644 index 02860b9b9..000000000 --- a/src/agentex/lib/cli/templates/default-codex/Dockerfile-uv.j2 +++ /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 \ - nodejs \ - npm \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/** - -# Install the codex CLI: the agent shells out to `codex` on every turn, so the -# binary must be present in the runtime image. -RUN npm install -g @openai/codex - -ENV UV_COMPILE_BYTECODE=1 -ENV UV_LINK_MODE=copy -ENV UV_HTTP_TIMEOUT=1000 - -WORKDIR /app/{{ project_path_from_build_root }} - -# Copy dependency files for layer caching -COPY {{ project_path_from_build_root }}/pyproject.toml ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-install-project --no-dev - -# Copy the project code -COPY {{ project_path_from_build_root }}/project ./project - -# Install the project -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-dev - -ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" -ENV PYTHONPATH=/app - -# Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-codex/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-codex/Dockerfile.j2 deleted file mode 100644 index 1a8eb1484..000000000 --- a/src/agentex/lib/cli/templates/default-codex/Dockerfile.j2 +++ /dev/null @@ -1,46 +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 the codex CLI: the agent shells out to `codex` on every turn, so the -# binary must be present in the runtime image. -RUN npm install -g @openai/codex - -RUN uv pip install --system --upgrade pip setuptools wheel - -ENV UV_HTTP_TIMEOUT=1000 - -# Copy just the requirements file to optimize caching -COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt - -WORKDIR /app/{{ project_path_from_build_root }} - -# Install the required Python packages -RUN uv pip install --system -r requirements.txt - -# Copy the project code -COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project - -# Set environment variables -ENV PYTHONPATH=/app - -# Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-codex/README.md.j2 b/src/agentex/lib/cli/templates/default-codex/README.md.j2 deleted file mode 100644 index b82f1c5f2..000000000 --- a/src/agentex/lib/cli/templates/default-codex/README.md.j2 +++ /dev/null @@ -1,72 +0,0 @@ -# {{ agent_name }} - AgentEx Async Codex Agent - -This template builds an **asynchronous** (non-Temporal) agent that drives the -**Codex CLI** through the unified harness surface on AgentEx: -- Spawns `codex exec --json` as a local subprocess -- Wraps the CLI's stdout stream in a `CodexTurn` -- Delivers canonical `StreamTaskMessage*` events via `UnifiedEmitter.auto_send_turn` - (the async Redis push path), so the UI receives output in real time -- Persists the codex session/thread ID via `adk.state` for multi-turn memory -- Tracing integration to SGP / AgentEx - -## Prerequisites - -- The `codex` CLI installed and on your `PATH` (`npm install -g @openai/codex`) -- An `OPENAI_API_KEY` in your environment - -## Running the Agent - -```bash -agentex agents run --manifest manifest.yaml -``` - -## Project Structure - -``` -{{ project_name }}/ -โ”œโ”€โ”€ project/ -โ”‚ โ”œโ”€โ”€ __init__.py -โ”‚ โ””โ”€โ”€ acp.py # ACP server, subprocess spawn, state, and event handlers -โ”œโ”€โ”€ Dockerfile -โ”œโ”€โ”€ manifest.yaml -โ”œโ”€โ”€ dev.ipynb -{% if use_uv %} -โ””โ”€โ”€ pyproject.toml -{% else %} -โ””โ”€โ”€ requirements.txt -{% endif %} -``` - -## Key Concepts - -### Async ACP with the harness -The async ACP model streams events over Redis instead of an HTTP response. The -`@acp.on_task_event_send` handler spawns the Codex CLI and pushes the harness -events to the task stream. - -### Multi-turn memory -The codex session/thread ID is persisted via `adk.state`, so each new turn -resumes the same codex session with `codex exec resume `. - -### The unified harness surface -`CodexTurn` + `UnifiedEmitter` are the unified harness surface. The turn -normalizes CLI output into canonical AgentEx events; the emitter traces and -delivers them. - -## Development - -### 1. Choose a model -Set `CODEX_MODEL` (defaults to `o4-mini`) to control which model codex uses. - -### 2. Customize the subprocess -Edit `_spawn_codex` in `project/acp.py` to change the CLI flags or how the -prompt is delivered. - -### 3. Configure Credentials -Set your credentials via `manifest.yaml`, an exported environment variable, or a -`.env` file in the project directory. - -### 4. Run Locally -```bash -export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml -``` diff --git a/src/agentex/lib/cli/templates/default-codex/dev.ipynb.j2 b/src/agentex/lib/cli/templates/default-codex/dev.ipynb.j2 deleted file mode 100644 index d3a68303f..000000000 --- a/src/agentex/lib/cli/templates/default-codex/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-codex/environments.yaml.j2 b/src/agentex/lib/cli/templates/default-codex/environments.yaml.j2 deleted file mode 100644 index f802776f0..000000000 --- a/src/agentex/lib/cli/templates/default-codex/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-codex/manifest.yaml.j2 b/src/agentex/lib/cli/templates/default-codex/manifest.yaml.j2 deleted file mode 100644 index 3c894318f..000000000 --- a/src/agentex/lib/cli/templates/default-codex/manifest.yaml.j2 +++ /dev/null @@ -1,123 +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 | tojson }} - - # Temporal workflow configuration - # Set enabled: true to use Temporal workflows for long-running tasks - temporal: - enabled: false - - # Optional: Credentials mapping - # Maps Kubernetes secrets to environment variables - # Common credentials include: - credentials: - # The codex CLI (`codex exec`) reads OPENAI_API_KEY directly; it does not - # use a LiteLLM key. - - env_var_name: OPENAI_API_KEY - secret_name: openai-api-key - secret_key: api-key - - env_var_name: SGP_API_KEY - secret_name: sgp-api-key - secret_key: api-key - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - - # Optional: Set Environment variables for running your agent locally as well - # as for deployment later on. OPENAI_API_KEY is supplied via the credential - # mapping above (deploy) or your local .env. Do NOT set it to an empty string - # here โ€” that would shadow the real key at runtime. - env: {} - # OPENAI_BASE_URL: "" - -# Deployment Configuration -# ----------------------- -# Configuration for deploying your agent to Kubernetes clusters -deployment: - # Container image configuration - image: - repository: "" # Update with your container registry - tag: "latest" # Default tag, should be versioned in production - - imagePullSecrets: [] # Update with your image pull secret names - # - name: my-registry-secret - - # Global deployment settings that apply to all clusters - # These can be overridden in cluster-specific environments (environments.yaml) - global: - # Default replica count - replicaCount: 1 - - # Default resource requirements - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-codex/project/acp.py.j2 b/src/agentex/lib/cli/templates/default-codex/project/acp.py.j2 deleted file mode 100644 index f676ef137..000000000 --- a/src/agentex/lib/cli/templates/default-codex/project/acp.py.j2 +++ /dev/null @@ -1,271 +0,0 @@ -"""Async (base) ACP handler for {{ agent_name }} โ€” a Codex CLI harness agent. - -Demonstrates the ``convert_codex_to_agentex_events`` tap + ``CodexTurn`` + -``UnifiedEmitter`` for an async (Redis-streaming) ACP agent without Temporal. - -The handler: -1. Spawns ``codex exec --json`` as a LOCAL asyncio subprocess (no sandbox). - This is correct for local development; production isolation is a separate - concern. -2. Wraps the stdout line stream in a ``CodexTurn``. -3. Delivers every canonical ``StreamTaskMessage*`` event to Redis via - ``UnifiedEmitter.auto_send_turn``, so the UI receives tokens in real time. -4. Multi-turn memory is persisted via ``adk.state``. - -Live runs require: -- ``codex`` CLI on PATH (``npm install -g @openai/codex``) -- ``OPENAI_API_KEY`` set in the environment -""" - -from __future__ import annotations - -import os -import time -import codecs -import asyncio -from collections.abc import AsyncIterator - -from dotenv import load_dotenv - -load_dotenv() - -import agentex.lib.adk as adk -from agentex.lib.adk import CodexTurn -from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams -from agentex.types.text_content import TextContent -from agentex.lib.core.harness import UnifiedEmitter -from agentex.lib.types.fastacp import AsyncACPConfig -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.lib.utils.model_utils import BaseModel -from agentex.lib.sdk.fastacp.fastacp import FastACP -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config - -logger = make_logger(__name__) - -add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SGP_API_KEY", ""), - sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - ) -) - -acp = FastACP.create( - acp_type="async", - config=AsyncACPConfig(type="base"), -) - -MODEL = os.environ.get("CODEX_MODEL", "o4-mini") - -# Serialize turns per task. Two ``task/event/send`` calls for the same task can -# otherwise both read the old ``codex_thread_id`` (or ``None``), run independent -# codex turns, and race to overwrite the stored thread id โ€” forking the session. -# A per-task lock keeps turns sequential without blocking other tasks. -_task_locks: dict[str, asyncio.Lock] = {} - - -def _task_lock(task_id: str) -> asyncio.Lock: - lock = _task_locks.get(task_id) - if lock is None: - lock = asyncio.Lock() - _task_locks[task_id] = lock - return lock - - -class ConversationState(BaseModel): - """Per-task conversation state persisted via ``adk.state``. - - We store the codex session/thread ID so subsequent turns can resume the - same codex session via ``codex exec resume ``. - """ - - codex_thread_id: str | None = None - turn_number: int = 0 - - -async def _spawn_codex( - model: str, - thread_id: str | None = None, -) -> asyncio.subprocess.Process: - """Spawn ``codex exec --json`` locally and return the live process. - - Injection seam: tests replace this function with a fake that returns a - mock process whose stdout yields pre-recorded event lines. - - When ``thread_id`` is provided the subcommand becomes - ``codex exec ... resume -`` so codex continues the prior - conversation thread. - - The caller writes the prompt to stdin after the process starts, then - closes stdin so codex knows input is complete. - """ - base_flags = [ - "--json", - "--skip-git-repo-check", - "--dangerously-bypass-approvals-and-sandbox", - "--model", - model, - ] - - if thread_id: - cmd = ["codex", "exec", *base_flags, "resume", thread_id, "-"] - else: - cmd = ["codex", "exec", *base_flags, "-"] - - return await asyncio.create_subprocess_exec( - *cmd, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - # Discard stderr: codex --json writes events to stdout; its stderr is - # progress/debug noise. Capturing it with PIPE but never reading it - # would deadlock once codex fills the OS pipe buffer (~64 KB). - stderr=asyncio.subprocess.DEVNULL, - env={**os.environ}, - ) - - -async def _process_stdout(process: asyncio.subprocess.Process) -> AsyncIterator[str]: - """Yield newline-delimited JSON lines from the process stdout. - - Uses an incremental UTF-8 decoder so a multibyte character split across two - 4 KB reads is decoded correctly instead of being corrupted at the boundary. - """ - assert process.stdout is not None - decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") - buffer = "" - while True: - chunk = await process.stdout.read(4096) - if not chunk: - break - buffer += decoder.decode(chunk) - while "\n" in buffer: - line, buffer = buffer.split("\n", 1) - line = line.strip() - if line: - yield line - buffer += decoder.decode(b"", final=True) - if buffer.strip(): - yield buffer.strip() - - -@acp.on_task_create -async def handle_task_create(params: CreateTaskParams): - """Initialize per-task state on task creation.""" - logger.info("Task created: %s", params.task.id) - await adk.state.create( - task_id=params.task.id, - agent_id=params.agent.id, - state=ConversationState(), - ) - - -@acp.on_task_event_send -async def handle_task_event_send(params: SendEventParams): - """Handle each user message: spawn codex, stream events, save thread ID.""" - task_id = params.task.id - agent_id = params.agent.id - - content = params.event.content - if not isinstance(content, TextContent): - logger.warning( - "Ignoring non-text event content (type=%s) for task %s", - getattr(content, "type", "?"), - task_id, - ) - return - user_message = content.content - - logger.info("Processing message for task %s", task_id) - - # Serialize the whole turn (echo + the read-modify-write of - # ``codex_thread_id``) so two concurrent turns on the same task cannot fork - # the codex session or interleave their echoed messages. - lock = _task_lock(task_id) - await lock.acquire() - try: - # Echo inside the lock so this turn's message stays ordered with it. - await adk.messages.create(task_id=task_id, content=content) - - task_state = await adk.state.get_by_task_and_agent(task_id=task_id, agent_id=agent_id) - if task_state is None: - state = ConversationState() - task_state = await adk.state.create(task_id=task_id, agent_id=agent_id, state=state) - else: - state = ConversationState.model_validate(task_state.state) - - state.turn_number += 1 - - async with adk.tracing.span( - trace_id=task_id, - task_id=task_id, - name=f"Turn {state.turn_number}", - input={"message": user_message}, - data={"__span_type__": "AGENT_WORKFLOW"}, - ) as turn_span: - start_ms = int(time.monotonic() * 1000) - - process = await _spawn_codex(MODEL, thread_id=state.codex_thread_id) - - assert process.stdin is not None - process.stdin.write(user_message.encode("utf-8")) - await process.stdin.drain() - process.stdin.close() - - turn = CodexTurn( - events=_process_stdout(process), - model=MODEL, - ) - - emitter = UnifiedEmitter( - task_id=task_id, - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - ) - - # Guarantee the subprocess is reaped even if auto_send_turn raises - # (e.g. a Redis error); otherwise codex stays blocked writing to a full - # stdout pipe buffer and the OS process leaks until the server restarts. - try: - result = await emitter.auto_send_turn(turn) - finally: - if process.returncode is None: - process.kill() - await process.wait() - - # Record the real wall-clock duration AFTER streaming completes; setting - # it before the stream ran would capture only subprocess spawn overhead. - turn.duration_ms = int(time.monotonic() * 1000) - start_ms - - usage = turn.usage() - - # Persist the codex session id (public accessor; valid post-stream) so the - # next turn resumes the same session. - if turn.session_id: - state.codex_thread_id = turn.session_id - - await adk.state.update( - state_id=task_state.id, - task_id=task_id, - agent_id=agent_id, - state=state, - ) - - if turn_span: - turn_span.output = { - "final_text": result.final_text, - "model": usage.model, - } - finally: - lock.release() - # Evict the lock once released and idle (unlocked, no waiters) so - # ``_task_locks`` stays bounded even if the turn raised. There is no - # await between ``_task_lock()`` and acquiring it, so an unlocked, - # waiter-free lock has no in-flight user. - if not lock.locked() and not getattr(lock, "_waiters", None): - _task_locks.pop(task_id, None) - - -@acp.on_task_cancel -async def handle_task_canceled(params: CancelTaskParams): - logger.info("Task canceled: %s", params.task.id) diff --git a/src/agentex/lib/cli/templates/default-codex/pyproject.toml.j2 b/src/agentex/lib/cli/templates/default-codex/pyproject.toml.j2 deleted file mode 100644 index e499b1dc1..000000000 --- a/src/agentex/lib/cli/templates/default-codex/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", - "python-dotenv>=1.0,<2", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "black", - "isort", - "flake8", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] - -[tool.black] -line-length = 88 -target-version = ['py312'] - -[tool.isort] -profile = "black" -line_length = 88 diff --git a/src/agentex/lib/cli/templates/default-codex/requirements.txt.j2 b/src/agentex/lib/cli/templates/default-codex/requirements.txt.j2 deleted file mode 100644 index 8c0630384..000000000 --- a/src/agentex/lib/cli/templates/default-codex/requirements.txt.j2 +++ /dev/null @@ -1,8 +0,0 @@ -# Install agentex-sdk from local path -agentex-sdk - -# Scale GenAI Platform Python SDK -scale-gp - -# Loads .env files for local development -python-dotenv>=1.0,<2 diff --git a/src/agentex/lib/cli/templates/default-langgraph/.dockerignore.j2 b/src/agentex/lib/cli/templates/default-langgraph/.dockerignore.j2 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 dd3035f7b..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 ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-install-project --no-dev - -# Copy the project code -COPY {{ project_path_from_build_root }}/project ./project - -# Install the project -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-dev - -ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" -ENV PYTHONPATH=/app - -# Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-langgraph/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-langgraph/Dockerfile.j2 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 e6c15cf33..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 | tojson }} - - # Temporal workflow configuration - # Set enabled: true to use Temporal workflows for long-running tasks - temporal: - enabled: false - - # Optional: Credentials mapping - # Maps Kubernetes secrets to environment variables - # Common credentials include: - credentials: - - env_var_name: LITELLM_API_KEY - secret_name: litellm-api-key - secret_key: api-key - - env_var_name: SGP_API_KEY - secret_name: sgp-api-key - secret_key: api-key - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - - # Optional: Set Environment variables for running your agent locally as well - # as for deployment later on - env: - LITELLM_API_KEY: "" # Set your LLM API key - # OPENAI_BASE_URL: "" - -# Deployment Configuration -# ----------------------- -# Configuration for deploying your agent to Kubernetes clusters -deployment: - # Container image configuration - image: - repository: "" # Update with your container registry - tag: "latest" # Default tag, should be versioned in production - - imagePullSecrets: [] # Update with your image pull secret names - # - name: my-registry-secret - - # Global deployment settings that apply to all clusters - # These can be overridden in cluster-specific environments (environments.yaml) - global: - # Default replica count - replicaCount: 1 - - # Default resource requirements - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-langgraph/project/acp.py.j2 b/src/agentex/lib/cli/templates/default-langgraph/project/acp.py.j2 deleted file mode 100644 index da5d37905..000000000 --- a/src/agentex/lib/cli/templates/default-langgraph/project/acp.py.j2 +++ /dev/null @@ -1,102 +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.core.harness import UnifiedEmitter -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config -from agentex.lib.sdk.fastacp.fastacp import FastACP -from agentex.protocol.acp import SendEventParams, CancelTaskParams, CreateTaskParams -from agentex.lib.types.fastacp import AsyncACPConfig -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.types.text_content import TextContent -from agentex.lib.adk import LangGraphTurn - -from project.graph import create_graph - -logger = make_logger(__name__) - -add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SGP_API_KEY", ""), - sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - )) - -acp = FastACP.create( - acp_type="async", - config=AsyncACPConfig(type="base"), -) - -_graph = None - - -async def get_graph(): - global _graph - if _graph is None: - _graph = await create_graph() - return _graph - - -@acp.on_task_event_send -async def handle_task_event_send(params: SendEventParams): - """Handle incoming events, streaming tokens and tool calls via Redis.""" - graph = await get_graph() - task_id = params.task.id - content = params.event.content - if not isinstance(content, TextContent): - logger.warning("Ignoring non-text event content (type=%s)", getattr(content, "type", "?")) - return - user_message = content.content - - logger.info(f"Processing message for thread {task_id}") - - # Echo the user's message - await adk.messages.create(task_id=task_id, content=params.event.content) - - async with adk.tracing.span( - trace_id=task_id, - name="message", - input={"message": user_message}, - data={"__span_type__": "AGENT_WORKFLOW"}, - ) as turn_span: - stream = graph.astream( - {"messages": [{"role": "user", "content": user_message}]}, - config={"configurable": {"thread_id": task_id}}, - stream_mode=["messages", "updates"], - ) - - turn = LangGraphTurn(stream, model=None) - emitter = UnifiedEmitter( - task_id=task_id, - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - ) - - result = await emitter.auto_send_turn(turn) - - if turn_span: - turn_span.output = {"final_output": result.final_text} - - -@acp.on_task_create -async def handle_task_create(params: CreateTaskParams): - logger.info(f"Task created: {params.task.id}") - - -@acp.on_task_cancel -async def handle_task_canceled(params: CancelTaskParams): - logger.info(f"Task canceled: {params.task.id}") diff --git a/src/agentex/lib/cli/templates/default-langgraph/project/graph.py.j2 b/src/agentex/lib/cli/templates/default-langgraph/project/graph.py.j2 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-openai-agents/.dockerignore.j2 b/src/agentex/lib/cli/templates/default-openai-agents/.dockerignore.j2 deleted file mode 100644 index c2d7fca4d..000000000 --- a/src/agentex/lib/cli/templates/default-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/default-openai-agents/.env.example.j2 b/src/agentex/lib/cli/templates/default-openai-agents/.env.example.j2 deleted file mode 100644 index 015f49ef7..000000000 --- a/src/agentex/lib/cli/templates/default-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/default-openai-agents/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile-uv.j2 deleted file mode 100644 index dd3035f7b..000000000 --- a/src/agentex/lib/cli/templates/default-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 ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-install-project --no-dev - -# Copy the project code -COPY {{ project_path_from_build_root }}/project ./project - -# Install the project -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-dev - -ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" -ENV PYTHONPATH=/app - -# Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-openai-agents/Dockerfile.j2 deleted file mode 100644 index 056d60b96..000000000 --- a/src/agentex/lib/cli/templates/default-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 \ - nodejs \ - npm \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -RUN uv pip install --system --upgrade pip setuptools wheel - -ENV UV_HTTP_TIMEOUT=1000 - -# Copy just the requirements file to optimize caching -COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt - -WORKDIR /app/{{ project_path_from_build_root }} - -# Install the required Python packages -RUN uv pip install --system -r requirements.txt - -# Copy the project code -COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project - - -# Set environment variables -ENV PYTHONPATH=/app - -# Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-openai-agents/README.md.j2 b/src/agentex/lib/cli/templates/default-openai-agents/README.md.j2 deleted file mode 100644 index 9611e83bd..000000000 --- a/src/agentex/lib/cli/templates/default-openai-agents/README.md.j2 +++ /dev/null @@ -1,69 +0,0 @@ -# {{ agent_name }} - AgentEx Async OpenAI Agents SDK Agent - -This template builds an **asynchronous** (non-Temporal) agent built on the -**OpenAI Agents SDK**, delivered through the unified harness surface on AgentEx: -- Defines an OpenAI Agents SDK `Agent` (with an example weather tool) inline in - `acp.py` -- Wraps the SDK run in an `OpenAITurn` -- Delivers canonical `StreamTaskMessage*` events via `UnifiedEmitter.auto_send_turn` - (the async Redis push path), so the UI receives output in real time -- Tracing integration to SGP / AgentEx - -## Prerequisites - -- An `OPENAI_API_KEY` in your environment (or a `LITELLM_API_KEY`, which is - copied to `OPENAI_API_KEY` for LiteLLM-proxy compatibility) - -## Running the Agent - -```bash -agentex agents run --manifest manifest.yaml -``` - -## Project Structure - -``` -{{ project_name }}/ -โ”œโ”€โ”€ project/ -โ”‚ โ”œโ”€โ”€ __init__.py -โ”‚ โ””โ”€โ”€ acp.py # ACP server, agent + tool definitions, event handlers -โ”œโ”€โ”€ Dockerfile -โ”œโ”€โ”€ manifest.yaml -โ”œโ”€โ”€ dev.ipynb -{% if use_uv %} -โ””โ”€โ”€ pyproject.toml -{% else %} -โ””โ”€โ”€ requirements.txt -{% endif %} -``` - -## Key Concepts - -### Async ACP with the harness -The async ACP model streams events over Redis instead of an HTTP response. The -`@acp.on_task_event_send` handler runs the OpenAI Agents SDK and pushes the -harness events to the task stream. - -### The unified harness surface -`OpenAITurn` + `UnifiedEmitter` are the unified harness surface. The turn -normalizes the SDK's streamed run into canonical AgentEx events; the emitter -traces and delivers them. - -## Development - -### 1. Add Your Own Tools -Define new `@function_tool` functions in `project/acp.py` and add them to the -agent's `tools=[...]` list in `create_agent()`. - -### 2. Customize the Agent -Edit `MODEL_NAME` and `INSTRUCTIONS` in `project/acp.py` to change the model or -system prompt. - -### 3. Configure Credentials -Set your credentials via `manifest.yaml`, an exported environment variable, or a -`.env` file in the project directory. - -### 4. Run Locally -```bash -export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml -``` diff --git a/src/agentex/lib/cli/templates/default-openai-agents/dev.ipynb.j2 b/src/agentex/lib/cli/templates/default-openai-agents/dev.ipynb.j2 deleted file mode 100644 index b0691b1b1..000000000 --- a/src/agentex/lib/cli/templates/default-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_update)} object in streaming message.\")\n", - " elif isinstance(task_message_update, StreamTaskMessageFull):\n", - " content = task_message_update.content\n", - " if isinstance(content, TextContent):\n", - " print(content.content)\n", - " else:\n", - " print(f\"Found non-text {type(task_message_update)} object in full message.\")\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c5e7e042", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": ".venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.9" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/src/agentex/lib/cli/templates/default-openai-agents/environments.yaml.j2 b/src/agentex/lib/cli/templates/default-openai-agents/environments.yaml.j2 deleted file mode 100644 index 73924abdd..000000000 --- a/src/agentex/lib/cli/templates/default-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/default-openai-agents/manifest.yaml.j2 b/src/agentex/lib/cli/templates/default-openai-agents/manifest.yaml.j2 deleted file mode 100644 index b633518be..000000000 --- a/src/agentex/lib/cli/templates/default-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: async - # Unique name for your agent - # Used for task routing and monitoring - name: {{ agent_name }} - - # Description of what your agent does - # Helps with documentation and discovery - description: {{ description | tojson }} - - # Temporal workflow configuration - # Set enabled: true to use Temporal workflows for long-running tasks - temporal: - enabled: false - - # Optional: Credentials mapping - # Maps Kubernetes secrets to environment variables - # Common credentials include: - credentials: [] # Update with your credentials - # - env_var_name: LITELLM_API_KEY - # secret_name: litellm-api-key - # secret_key: api-key - - # Optional: Set Environment variables for running your agent locally as well - # as for deployment later on - env: {} # Update with your environment variables - # LITELLM_API_KEY: "" - # OPENAI_BASE_URL: "" - # OPENAI_ORG_ID: "" - - -# Deployment Configuration -# ----------------------- -# Configuration for deploying your agent to Kubernetes clusters -deployment: - # Container image configuration - image: - repository: "" # Update with your container registry - tag: "latest" # Default tag, should be versioned in production - - imagePullSecrets: [] # Update with your image pull secret names - # - name: my-registry-secret - - # Global deployment settings that apply to all clusters - # These can be overridden in cluster-specific environments (environments.yaml) - global: - # Default replica count - replicaCount: 1 - - # Default resource requirements - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-openai-agents/project/acp.py.j2 b/src/agentex/lib/cli/templates/default-openai-agents/project/acp.py.j2 deleted file mode 100644 index 66ee31243..000000000 --- a/src/agentex/lib/cli/templates/default-openai-agents/project/acp.py.j2 +++ /dev/null @@ -1,171 +0,0 @@ -"""ACP handler for {{ agent_name }} โ€” an async OpenAI Agents SDK agent. - -Uses the async ACP model with Redis streaming instead of HTTP yields. The -OpenAI Agents SDK run is wrapped in an ``OpenAITurn`` and pushed to the task -stream via ``UnifiedEmitter.auto_send_turn`` โ€” the async delivery path of the -unified harness surface. ``auto_send_turn`` returns a ``TurnResult`` carrying -the accumulated final text and normalized usage. - -The agent and its tools are defined inline below so this template stays a -single, self-contained ``acp.py``. -""" - -from __future__ import annotations - -import os -from typing import List -from datetime import datetime - -from dotenv import load_dotenv - -load_dotenv() - -from agents import Agent, Runner, function_tool, set_tracing_disabled - -from agentex.lib import adk -from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams -from agentex.lib.types.fastacp import AsyncACPConfig -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.types.text_content import TextContent -from agentex.lib.utils.model_utils import BaseModel -from agentex.lib.sdk.fastacp.fastacp import FastACP -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.lib.adk import OpenAITurn -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config - -# Disable the openai-agents SDK's native tracer so it doesn't ship traces to -# api.openai.com using OPENAI_API_KEY (which may be a LiteLLM proxy key). -# SGP tracing below still runs via the Agentex tracing manager. -set_tracing_disabled(True) - -logger = make_logger(__name__) - -# LiteLLM proxy auth: copy LITELLM_API_KEY to OPENAI_API_KEY for OpenAI client compatibility. -_litellm_key = os.environ.get("LITELLM_API_KEY") -if _litellm_key and not os.environ.get("OPENAI_API_KEY"): - os.environ["OPENAI_API_KEY"] = _litellm_key - -_sgp_api_key = os.environ.get("SGP_API_KEY", "") -_sgp_account_id = os.environ.get("SGP_ACCOUNT_ID", "") -if _sgp_api_key and _sgp_account_id: - add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=_sgp_api_key, - sgp_account_id=_sgp_account_id, - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - ) - ) - -acp = FastACP.create( - acp_type="async", - config=AsyncACPConfig(type="base"), -) - -MODEL_NAME = "gpt-4o" -INSTRUCTIONS = """You are a helpful AI assistant with access to tools. - -Current date and time: {timestamp} - -Guidelines: -- Be concise and helpful -- Use the weather tool when the user asks about the weather -- Always report the real tool output back to the user -""" - - -@function_tool -def get_weather(city: str) -> str: - """Get the current weather for a city.""" - return f"The weather in {city} is sunny and 72ยฐF" - - -def create_agent() -> Agent: - """Build and return the OpenAI Agents SDK agent with the weather tool.""" - return Agent( - name="{{ agent_name }}", - model=MODEL_NAME, - instructions=INSTRUCTIONS.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")), - tools=[get_weather], - ) - - -def get_agent() -> Agent: - """Build a fresh agent per request so the timestamp in the instructions stays current.""" - return create_agent() - - -class StateModel(BaseModel): - """Per-task conversation state persisted between turns.""" - - input_list: List[dict] - turn_number: int - - -@acp.on_task_create -async def handle_task_create(params: CreateTaskParams): - logger.info(f"Task created: {params.task.id}") - - -@acp.on_task_event_send -async def handle_task_event_send(params: SendEventParams): - """Handle each user message: run the agent and auto-send its turn.""" - agent = get_agent() - task_id = params.task.id - agent_id = params.agent.id - content = params.event.content - if not isinstance(content, TextContent): - logger.warning("Ignoring non-text event content (type=%s)", getattr(content, "type", "?")) - return - user_message = content.content - - logger.info(f"Processing message for task {task_id}") - - # Echo the user's message into the task history. - await adk.messages.create(task_id=task_id, content=params.event.content) - - # Load (or create) the persisted conversation history for this task so the - # agent can see prior turns, then append the new user message. - task_state = await adk.state.get_by_task_and_agent(task_id=task_id, agent_id=agent_id) - if task_state is None: - state = StateModel(input_list=[], turn_number=0) - task_state = await adk.state.create(task_id=task_id, agent_id=agent_id, state=state) - else: - state = StateModel.model_validate(task_state.state) - - state.turn_number += 1 - state.input_list.append({"role": "user", "content": user_message}) - - async with adk.tracing.span( - trace_id=task_id, - task_id=task_id, - name="message", - input={"message": user_message}, - data={"__span_type__": "AGENT_WORKFLOW"}, - ) as turn_span: - result = Runner.run_streamed(starting_agent=agent, input=state.input_list) - turn = OpenAITurn(result=result, model=MODEL_NAME) - emitter = UnifiedEmitter( - task_id=task_id, - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - ) - turn_result = await emitter.auto_send_turn(turn) - - # Persist the full conversation history (user + assistant + tool calls) - # so the next turn resumes with complete context. - state.input_list = result.to_input_list() - await adk.state.update( - state_id=task_state.id, - task_id=task_id, - agent_id=agent_id, - state=state, - ) - - if turn_span: - turn_span.output = {"final_output": turn_result.final_text} - - -@acp.on_task_cancel -async def handle_task_canceled(params: CancelTaskParams): - logger.info(f"Task canceled: {params.task.id}") diff --git a/src/agentex/lib/cli/templates/default-openai-agents/pyproject.toml.j2 b/src/agentex/lib/cli/templates/default-openai-agents/pyproject.toml.j2 deleted file mode 100644 index 4b9c7ed71..000000000 --- a/src/agentex/lib/cli/templates/default-openai-agents/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", - "openai-agents", - "python-dotenv>=1.0,<2", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "black", - "isort", - "flake8", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] - -[tool.black] -line-length = 88 -target-version = ['py312'] - -[tool.isort] -profile = "black" -line_length = 88 diff --git a/src/agentex/lib/cli/templates/default-openai-agents/requirements.txt.j2 b/src/agentex/lib/cli/templates/default-openai-agents/requirements.txt.j2 deleted file mode 100644 index 14779c089..000000000 --- a/src/agentex/lib/cli/templates/default-openai-agents/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 -openai-agents - -# Loads .env files for local development -python-dotenv>=1.0,<2 diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/.dockerignore.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/.dockerignore.j2 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 dd3035f7b..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 ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-install-project --no-dev - -# Copy the project code -COPY {{ project_path_from_build_root }}/project ./project - -# Install the project -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-dev - -ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" -ENV PYTHONPATH=/app - -# Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/Dockerfile.j2 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 e6c15cf33..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 | tojson }} - - # Temporal workflow configuration - # Set enabled: true to use Temporal workflows for long-running tasks - temporal: - enabled: false - - # Optional: Credentials mapping - # Maps Kubernetes secrets to environment variables - # Common credentials include: - credentials: - - env_var_name: LITELLM_API_KEY - secret_name: litellm-api-key - secret_key: api-key - - env_var_name: SGP_API_KEY - secret_name: sgp-api-key - secret_key: api-key - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - - # Optional: Set Environment variables for running your agent locally as well - # as for deployment later on - env: - LITELLM_API_KEY: "" # Set your LLM API key - # OPENAI_BASE_URL: "" - -# Deployment Configuration -# ----------------------- -# Configuration for deploying your agent to Kubernetes clusters -deployment: - # Container image configuration - image: - repository: "" # Update with your container registry - tag: "latest" # Default tag, should be versioned in production - - imagePullSecrets: [] # Update with your image pull secret names - # - name: my-registry-secret - - # Global deployment settings that apply to all clusters - # These can be overridden in cluster-specific environments (environments.yaml) - global: - # Default replica count - replicaCount: 1 - - # Default resource requirements - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/project/acp.py.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/project/acp.py.j2 deleted file mode 100644 index 245f9ec38..000000000 --- a/src/agentex/lib/cli/templates/default-pydantic-ai/project/acp.py.j2 +++ /dev/null @@ -1,170 +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 MODEL_NAME, create_agent -from pydantic_ai.run import AgentRunResultEvent -from pydantic_ai.messages import ModelMessagesTypeAdapter - -import agentex.lib.adk as adk -from agentex.protocol.acp import SendEventParams, CancelTaskParams, CreateTaskParams -from agentex.lib.core.harness import UnifiedEmitter -from agentex.lib.types.fastacp import AsyncACPConfig -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.types.text_content import TextContent -from agentex.lib.utils.model_utils import BaseModel -from agentex.lib.sdk.fastacp.fastacp import FastACP -from agentex.lib.adk import PydanticAITurn -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config - -logger = make_logger(__name__) - -# Register the SGP tracing exporter. Spans also reach the AgentEx backend -# via the default Agentex processor that's lazy-initialised on first span, -# so they show up in the per-task spans dropdown out of the box. -SGP_API_KEY = os.environ.get("SGP_API_KEY", "") -SGP_ACCOUNT_ID = os.environ.get("SGP_ACCOUNT_ID", "") -if SGP_API_KEY and SGP_ACCOUNT_ID: - add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=SGP_API_KEY, - sgp_account_id=SGP_ACCOUNT_ID, - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - ) - ) - -acp = FastACP.create( - acp_type="async", - config=AsyncACPConfig(type="base"), -) - -_agent = None - - -def get_agent(): - """Return the cached Pydantic AI agent, creating it on first use.""" - global _agent - if _agent is None: - _agent = create_agent() - return _agent - - -class ConversationState(BaseModel): - """Per-task conversation state persisted via ``adk.state``. - - ``history_json`` holds the pydantic-ai message history serialized by - ``ModelMessagesTypeAdapter`` โ€” pydantic-ai's official way to round-trip - ``ModelMessage`` objects through JSON. We can't use a plain - ``list[ModelMessage]`` field because ``ModelMessage`` is a discriminated - union of runtime types, not a stable Pydantic schema. - """ - - history_json: str = "[]" - turn_number: int = 0 - - -@acp.on_task_create -async def handle_task_create(params: CreateTaskParams): - """Initialize per-task state on task creation.""" - logger.info(f"Task created: {params.task.id}") - await adk.state.create( - task_id=params.task.id, - agent_id=params.agent.id, - state=ConversationState(), - ) - - -@acp.on_task_event_send -async def handle_task_event_send(params: SendEventParams): - """Handle each user message: load prior history, run the agent, save updated history.""" - agent = get_agent() - task_id = params.task.id - agent_id = params.agent.id - content = params.event.content - if not isinstance(content, TextContent): - logger.warning("Ignoring non-text event content (type=%s)", getattr(content, "type", "?")) - return - user_message = content.content - - logger.info(f"Processing message for task {task_id}") - - # Echo the user's message into the task history. - await adk.messages.create(task_id=task_id, content=params.event.content) - - # Load prior conversation state. Fall back to a fresh state if missing - # (e.g. the task wasn't initialised through on_task_create). - task_state = await adk.state.get_by_task_and_agent(task_id=task_id, agent_id=agent_id) - if task_state is None: - state = ConversationState() - task_state = await adk.state.create(task_id=task_id, agent_id=agent_id, state=state) - else: - state = ConversationState.model_validate(task_state.state) - - state.turn_number += 1 - previous_messages = ModelMessagesTypeAdapter.validate_json(state.history_json) - - async with adk.tracing.span( - trace_id=task_id, - task_id=task_id, - name=f"Turn {state.turn_number}", - input={"message": user_message}, - data={"__span_type__": "AGENT_WORKFLOW"}, - ) as turn_span: - # Construct the UnifiedEmitter from the ACP context so tracing is - # automatic and messages are auto-sent to the task stream (Redis). - emitter = UnifiedEmitter( - task_id=task_id, - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - ) - - # Wrap the pydantic-ai event stream so we can capture the final - # AgentRunResultEvent (which carries the full message list for the - # next turn) before forwarding events to the emitter. - captured_messages: list[Any] = [] - - async def tee_messages(upstream) -> AsyncIterator[Any]: - async for event in upstream: - if isinstance(event, AgentRunResultEvent): - captured_messages[:] = list(event.result.all_messages()) - yield event - - async with agent.run_stream_events(user_message, message_history=previous_messages) as stream: - turn = PydanticAITurn(tee_messages(stream), model=MODEL_NAME) - result = await emitter.auto_send_turn(turn) - - # Save the updated message history so the next turn picks up here. - if captured_messages: - state.history_json = ModelMessagesTypeAdapter.dump_json(captured_messages).decode() - await adk.state.update( - state_id=task_state.id, - task_id=task_id, - agent_id=agent_id, - state=state, - ) - - if turn_span: - turn_span.output = {"final_output": result.final_text} - - -@acp.on_task_cancel -async def handle_task_canceled(params: CancelTaskParams): - logger.info(f"Task canceled: {params.task.id}") diff --git a/src/agentex/lib/cli/templates/default-pydantic-ai/project/agent.py.j2 b/src/agentex/lib/cli/templates/default-pydantic-ai/project/agent.py.j2 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 dd3035f7b..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 ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-install-project --no-dev - -# Copy the project code -COPY {{ project_path_from_build_root }}/project ./project - -# Install the project -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-dev - -ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" -ENV PYTHONPATH=/app - -# Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default/Dockerfile.j2 b/src/agentex/lib/cli/templates/default/Dockerfile.j2 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 c78ce1f44..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 | tojson }} - - # Temporal workflow configuration - # Set enabled: true to use Temporal workflows for long-running tasks - temporal: - enabled: false - - # Optional: Credentials mapping - # Maps Kubernetes secrets to environment variables - # Common credentials include: - credentials: - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - # credentials: - # - env_var_name: LITELLM_API_KEY - # secret_name: litellm-api-key - # secret_key: api-key - - # Optional: Set Environment variables for running your agent locally as well - # as for deployment later on - env: {} - # LITELLM_API_KEY: "" - # OPENAI_BASE_URL: "" - # OPENAI_ORG_ID: "" - -# Deployment Configuration -# ----------------------- -# Configuration for deploying your agent to Kubernetes clusters -deployment: - # Container image configuration - image: - repository: "" # Update with your container registry - tag: "latest" # Default tag, should be versioned in production - - imagePullSecrets: [] # Update with your image pull secret names - # - name: my-registry-secret - - # Global deployment settings that apply to all clusters - # These can be overridden in cluster-specific environments (environments.yaml) - global: - # Default replica count - replicaCount: 1 - - # Default resource requirements - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/default/project/acp.py.j2 b/src/agentex/lib/cli/templates/default/project/acp.py.j2 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-claude-code/.dockerignore.j2 b/src/agentex/lib/cli/templates/sync-claude-code/.dockerignore.j2 deleted file mode 100644 index c2d7fca4d..000000000 --- a/src/agentex/lib/cli/templates/sync-claude-code/.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-claude-code/.env.example.j2 b/src/agentex/lib/cli/templates/sync-claude-code/.env.example.j2 deleted file mode 100644 index 5aff34a60..000000000 --- a/src/agentex/lib/cli/templates/sync-claude-code/.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 the Claude Code CLI (the `claude` subprocess this agent spawns) -ANTHROPIC_API_KEY= - -# LLM base URL (optional - override to use a different provider) -# OPENAI_BASE_URL= - -# SGP Configuration (optional - for tracing) -# SGP_API_KEY= -# SGP_ACCOUNT_ID= -# SGP_CLIENT_BASE_URL= diff --git a/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 deleted file mode 100644 index 93d0f82d1..000000000 --- a/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 +++ /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 \ - nodejs \ - npm \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/** - -# Install the Claude Code CLI: the agent shells out to `claude` on every turn, -# so the binary must be present in the runtime image. -RUN npm install -g @anthropic-ai/claude-code - -ENV UV_COMPILE_BYTECODE=1 -ENV UV_LINK_MODE=copy -ENV UV_HTTP_TIMEOUT=1000 - -WORKDIR /app/{{ project_path_from_build_root }} - -# Copy dependency files for layer caching -COPY {{ project_path_from_build_root }}/pyproject.toml ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-install-project --no-dev - -# Copy the project code -COPY {{ project_path_from_build_root }}/project ./project - -# Install the project -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-dev - -ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" -ENV PYTHONPATH=/app - -# Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.j2 deleted file mode 100644 index 6cdc70799..000000000 --- a/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.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/* - -# Install the Claude Code CLI: the agent shells out to `claude` on every turn, -# so the binary must be present in the runtime image. -RUN npm install -g @anthropic-ai/claude-code - -RUN uv pip install --system --upgrade pip setuptools wheel - -ENV UV_HTTP_TIMEOUT=1000 - -# Copy just the requirements file to optimize caching -COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt - -WORKDIR /app/{{ project_path_from_build_root }} - -# Install the required Python packages -RUN uv pip install --system -r requirements.txt - -# Copy the project code -COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project - - -# Set environment variables -ENV PYTHONPATH=/app - -# Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-claude-code/README.md.j2 b/src/agentex/lib/cli/templates/sync-claude-code/README.md.j2 deleted file mode 100644 index 7e38eddec..000000000 --- a/src/agentex/lib/cli/templates/sync-claude-code/README.md.j2 +++ /dev/null @@ -1,64 +0,0 @@ -# {{ agent_name }} - AgentEx Sync Claude Code Agent - -This template builds a **synchronous** agent that drives the **Claude Code CLI** -through the unified harness surface on AgentEx: -- Spawns `claude -p --output-format stream-json --verbose` as a local subprocess -- Wraps the CLI's stdout stream in a `ClaudeCodeTurn` -- Delivers canonical `StreamTaskMessage*` events via `UnifiedEmitter.yield_turn` - (the sync HTTP yield path) -- Tracing integration to SGP / AgentEx - -## Prerequisites - -- The `claude` CLI installed and on your `PATH` -- An `ANTHROPIC_API_KEY` (or equivalent credential) in your environment - -## Running the Agent - -```bash -agentex agents run --manifest manifest.yaml -``` - -## Project Structure - -``` -{{ project_name }}/ -โ”œโ”€โ”€ project/ -โ”‚ โ”œโ”€โ”€ __init__.py -โ”‚ โ””โ”€โ”€ acp.py # ACP server, subprocess spawn, and message handler -โ”œโ”€โ”€ Dockerfile -โ”œโ”€โ”€ manifest.yaml -โ”œโ”€โ”€ dev.ipynb -{% if use_uv %} -โ””โ”€โ”€ pyproject.toml -{% else %} -โ””โ”€โ”€ requirements.txt -{% endif %} -``` - -## Key Concepts - -### Sync ACP with the harness -The sync ACP model uses HTTP request/response. The `@acp.on_message_send` -handler spawns the Claude Code CLI and yields the harness events back to the -client as they arrive. - -### The unified harness surface -`ClaudeCodeTurn` + `UnifiedEmitter` are the unified harness surface. The turn -normalizes CLI output into canonical AgentEx events; the emitter traces and -delivers them. - -## Development - -### 1. Customize the subprocess -Edit `_spawn_claude` in `project/acp.py` to change the CLI flags, working -directory, or how the prompt is delivered. - -### 2. Configure Credentials -Set your credentials via `manifest.yaml`, an exported environment variable, or a -`.env` file in the project directory. - -### 3. Run Locally -```bash -export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml -``` diff --git a/src/agentex/lib/cli/templates/sync-claude-code/dev.ipynb.j2 b/src/agentex/lib/cli/templates/sync-claude-code/dev.ipynb.j2 deleted file mode 100644 index b0691b1b1..000000000 --- a/src/agentex/lib/cli/templates/sync-claude-code/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_update)} object in streaming message.\")\n", - " elif isinstance(task_message_update, StreamTaskMessageFull):\n", - " content = task_message_update.content\n", - " if isinstance(content, TextContent):\n", - " print(content.content)\n", - " else:\n", - " print(f\"Found non-text {type(task_message_update)} object in full message.\")\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c5e7e042", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": ".venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.9" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/src/agentex/lib/cli/templates/sync-claude-code/environments.yaml.j2 b/src/agentex/lib/cli/templates/sync-claude-code/environments.yaml.j2 deleted file mode 100644 index 73924abdd..000000000 --- a/src/agentex/lib/cli/templates/sync-claude-code/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-claude-code/manifest.yaml.j2 b/src/agentex/lib/cli/templates/sync-claude-code/manifest.yaml.j2 deleted file mode 100644 index 4432d1a33..000000000 --- a/src/agentex/lib/cli/templates/sync-claude-code/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: sync - # Unique name for your agent - # Used for task routing and monitoring - name: {{ agent_name }} - - # Description of what your agent does - # Helps with documentation and discovery - description: {{ description | tojson }} - - # Temporal workflow configuration - # Set enabled: true to use Temporal workflows for long-running tasks - temporal: - enabled: false - - # Optional: Credentials mapping - # Maps Kubernetes secrets to environment variables - # Common credentials include: - credentials: - # The Claude Code CLI authenticates with ANTHROPIC_API_KEY (LITELLM_API_KEY - # is not read by the `claude` subprocess this agent spawns). - - env_var_name: ANTHROPIC_API_KEY - secret_name: anthropic-api-key - secret_key: api-key - - env_var_name: SGP_API_KEY - secret_name: sgp-api-key - secret_key: api-key - - # Optional: Set Environment variables for running your agent locally as well - # as for deployment later on. ANTHROPIC_API_KEY is supplied via the credential - # mapping above (deploy) or your local .env (load_dotenv). Do NOT set it to an - # empty string here โ€” that would shadow the real key at runtime. - env: {} - # ANTHROPIC_API_KEY: "" # uncomment only to hardcode for local runs - - -# Deployment Configuration -# ----------------------- -# Configuration for deploying your agent to Kubernetes clusters -deployment: - # Container image configuration - image: - repository: "" # Update with your container registry - tag: "latest" # Default tag, should be versioned in production - - imagePullSecrets: [] # Update with your image pull secret names - # - name: my-registry-secret - - # Global deployment settings that apply to all clusters - # These can be overridden in cluster-specific environments (environments.yaml) - global: - # Default replica count - replicaCount: 1 - - # Default resource requirements - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-claude-code/project/acp.py.j2 b/src/agentex/lib/cli/templates/sync-claude-code/project/acp.py.j2 deleted file mode 100644 index 33a89a51e..000000000 --- a/src/agentex/lib/cli/templates/sync-claude-code/project/acp.py.j2 +++ /dev/null @@ -1,155 +0,0 @@ -"""ACP handler for {{ agent_name }} โ€” a sync Claude Code agent. - -Spawns ``claude -p --output-format stream-json --verbose`` as a LOCAL -asyncio subprocess (no Scale sandbox โ€” that is a production concern). Stdout -lines are fed into ``ClaudeCodeTurn``, which wraps -``convert_claude_code_to_agentex_events``. Events are delivered via -``UnifiedEmitter.yield_turn``, the sync HTTP yield path. - -Live runs require the ``claude`` CLI to be installed and an -ANTHROPIC_API_KEY (or equivalent credential) to be in the environment. -""" - -from __future__ import annotations - -import os -import asyncio -from typing import AsyncIterator, AsyncGenerator -from collections import deque - -from dotenv import load_dotenv - -load_dotenv() - -import agentex.lib.adk as adk -from agentex.lib.adk import ClaudeCodeTurn -from agentex.lib.types.acp import SendMessageParams -from agentex.lib.core.harness import UnifiedEmitter -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.types.text_content import TextContent -from agentex.lib.sdk.fastacp.fastacp import FastACP -from agentex.types.task_message_update import TaskMessageUpdate -from agentex.types.task_message_content import TaskMessageContent -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config - -logger = make_logger(__name__) - -add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SGP_API_KEY", ""), - sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - ) -) - -acp = FastACP.create(acp_type="sync") - - -async def _spawn_claude(prompt: str) -> AsyncIterator[str]: - """Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines. - - This is a seam: tests can replace it with a fake async iterator of - pre-recorded lines so no real CLI invocation is needed offline. - """ - proc = await asyncio.create_subprocess_exec( - "claude", - "-p", - "--output-format", - "stream-json", - "--verbose", - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - assert proc.stdout is not None - assert proc.stdin is not None - - proc.stdin.write(prompt.encode()) - await proc.stdin.drain() - proc.stdin.close() - - # Drain stderr concurrently. With --verbose, Claude Code can write enough to - # stderr to fill the OS pipe buffer; if we only read stdout, the CLI blocks - # on its stderr write while we block reading stdout โ€” a deadlock. A - # background task keeps stderr flowing so stdout never stalls. We keep a - # bounded tail so a non-zero exit can be surfaced with context instead of - # silently completing the turn. - stderr_tail: deque[str] = deque(maxlen=20) - - async def _drain_stderr() -> None: - assert proc.stderr is not None - async for raw in proc.stderr: - text = raw.decode("utf-8", errors="replace").rstrip() - if text: - stderr_tail.append(text) - - stderr_task = asyncio.create_task(_drain_stderr()) - - try: - buffer = "" - async for chunk in proc.stdout: - buffer += chunk.decode("utf-8", errors="replace") - while "\n" in buffer: - line, buffer = buffer.split("\n", 1) - line = line.strip() - if line: - yield line - - if buffer.strip(): - yield buffer.strip() - - await proc.wait() - if proc.returncode: - # The CLI failed (missing binary/auth, bad command). Raise so the - # turn surfaces as failed instead of completing with no output. - tail = "\n".join(stderr_tail) - raise RuntimeError( - f"claude CLI exited with status {proc.returncode}:\n{tail}" - ) - finally: - # Release the subprocess and stderr drain task even if the consumer - # abandons the generator early (task cancellation / client disconnect): - # cancel the drain task and terminate+reap the process if it is still - # running, so neither is leaked. - stderr_task.cancel() - try: - await stderr_task - except asyncio.CancelledError: - pass - if proc.returncode is None: - try: - proc.terminate() - except ProcessLookupError: - pass - await proc.wait() - - -@acp.on_message_send -async def handle_message_send( - params: SendMessageParams, -) -> TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]: - """Handle an incoming message: run Claude Code locally and stream events.""" - task_id = params.task.id - content = params.content - if not isinstance(content, TextContent): - logger.warning("Ignoring non-text message content (type=%s)", getattr(content, "type", "?")) - return - prompt = content.content - logger.info("Processing message for task %s", task_id) - - async with adk.tracing.span( - trace_id=task_id, - task_id=task_id, - name="message", - input={"message": prompt}, - data={"__span_type__": "AGENT_WORKFLOW"}, - ) as turn_span: - emitter = UnifiedEmitter( - task_id=task_id, - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - ) - turn = ClaudeCodeTurn(_spawn_claude(prompt)) - async for event in emitter.yield_turn(turn): - yield event diff --git a/src/agentex/lib/cli/templates/sync-claude-code/pyproject.toml.j2 b/src/agentex/lib/cli/templates/sync-claude-code/pyproject.toml.j2 deleted file mode 100644 index e499b1dc1..000000000 --- a/src/agentex/lib/cli/templates/sync-claude-code/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", - "python-dotenv>=1.0,<2", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "black", - "isort", - "flake8", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] - -[tool.black] -line-length = 88 -target-version = ['py312'] - -[tool.isort] -profile = "black" -line_length = 88 diff --git a/src/agentex/lib/cli/templates/sync-claude-code/requirements.txt.j2 b/src/agentex/lib/cli/templates/sync-claude-code/requirements.txt.j2 deleted file mode 100644 index 8c0630384..000000000 --- a/src/agentex/lib/cli/templates/sync-claude-code/requirements.txt.j2 +++ /dev/null @@ -1,8 +0,0 @@ -# Install agentex-sdk from local path -agentex-sdk - -# Scale GenAI Platform Python SDK -scale-gp - -# Loads .env files for local development -python-dotenv>=1.0,<2 diff --git a/src/agentex/lib/cli/templates/sync-codex/.dockerignore.j2 b/src/agentex/lib/cli/templates/sync-codex/.dockerignore.j2 deleted file mode 100644 index c2d7fca4d..000000000 --- a/src/agentex/lib/cli/templates/sync-codex/.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-codex/.env.example.j2 b/src/agentex/lib/cli/templates/sync-codex/.env.example.j2 deleted file mode 100644 index 5d621a83e..000000000 --- a/src/agentex/lib/cli/templates/sync-codex/.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 used by the codex CLI (`codex exec` reads OPENAI_API_KEY directly) -OPENAI_API_KEY= - -# LLM base URL (optional - override to use a different provider) -# OPENAI_BASE_URL= - -# SGP Configuration (optional - for tracing) -# SGP_API_KEY= -# SGP_ACCOUNT_ID= -# SGP_CLIENT_BASE_URL= diff --git a/src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 deleted file mode 100644 index 02860b9b9..000000000 --- a/src/agentex/lib/cli/templates/sync-codex/Dockerfile-uv.j2 +++ /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 \ - nodejs \ - npm \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/** - -# Install the codex CLI: the agent shells out to `codex` on every turn, so the -# binary must be present in the runtime image. -RUN npm install -g @openai/codex - -ENV UV_COMPILE_BYTECODE=1 -ENV UV_LINK_MODE=copy -ENV UV_HTTP_TIMEOUT=1000 - -WORKDIR /app/{{ project_path_from_build_root }} - -# Copy dependency files for layer caching -COPY {{ project_path_from_build_root }}/pyproject.toml ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-install-project --no-dev - -# Copy the project code -COPY {{ project_path_from_build_root }}/project ./project - -# Install the project -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-dev - -ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" -ENV PYTHONPATH=/app - -# Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-codex/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-codex/Dockerfile.j2 deleted file mode 100644 index afa4470d9..000000000 --- a/src/agentex/lib/cli/templates/sync-codex/Dockerfile.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/* - -# Install the codex CLI: the agent shells out to `codex` on every turn, so the -# binary must be present in the runtime image. -RUN npm install -g @openai/codex - -RUN uv pip install --system --upgrade pip setuptools wheel - -ENV UV_HTTP_TIMEOUT=1000 - -# Copy just the requirements file to optimize caching -COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt - -WORKDIR /app/{{ project_path_from_build_root }} - -# Install the required Python packages -RUN uv pip install --system -r requirements.txt - -# Copy the project code -COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project - - -# Set environment variables -ENV PYTHONPATH=/app - -# Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-codex/README.md.j2 b/src/agentex/lib/cli/templates/sync-codex/README.md.j2 deleted file mode 100644 index 4ca1aeccf..000000000 --- a/src/agentex/lib/cli/templates/sync-codex/README.md.j2 +++ /dev/null @@ -1,67 +0,0 @@ -# {{ agent_name }} - AgentEx Sync Codex Agent - -This template builds a **synchronous** agent that drives the **Codex CLI** -through the unified harness surface on AgentEx: -- Spawns `codex exec --json` as a local subprocess -- Wraps the CLI's stdout stream in a `CodexTurn` -- Delivers canonical `StreamTaskMessage*` events via `UnifiedEmitter.yield_turn` - (the sync HTTP yield path) -- Tracing integration to SGP / AgentEx - -## Prerequisites - -- The `codex` CLI installed and on your `PATH` (`npm install -g @openai/codex`) -- An `OPENAI_API_KEY` in your environment - -## Running the Agent - -```bash -agentex agents run --manifest manifest.yaml -``` - -## Project Structure - -``` -{{ project_name }}/ -โ”œโ”€โ”€ project/ -โ”‚ โ”œโ”€โ”€ __init__.py -โ”‚ โ””โ”€โ”€ acp.py # ACP server, subprocess spawn, and message handler -โ”œโ”€โ”€ Dockerfile -โ”œโ”€โ”€ manifest.yaml -โ”œโ”€โ”€ dev.ipynb -{% if use_uv %} -โ””โ”€โ”€ pyproject.toml -{% else %} -โ””โ”€โ”€ requirements.txt -{% endif %} -``` - -## Key Concepts - -### Sync ACP with the harness -The sync ACP model uses HTTP request/response. The `@acp.on_message_send` -handler spawns the Codex CLI and yields the harness events back to the client -as they arrive. - -### The unified harness surface -`CodexTurn` + `UnifiedEmitter` are the unified harness surface. The turn -normalizes CLI output into canonical AgentEx events; the emitter traces and -delivers them. - -## Development - -### 1. Choose a model -Set `CODEX_MODEL` (defaults to `o4-mini`) to control which model codex uses. - -### 2. Customize the subprocess -Edit `_spawn_codex` in `project/acp.py` to change the CLI flags or how the -prompt is delivered. - -### 3. Configure Credentials -Set your credentials via `manifest.yaml`, an exported environment variable, or a -`.env` file in the project directory. - -### 4. Run Locally -```bash -export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml -``` diff --git a/src/agentex/lib/cli/templates/sync-codex/dev.ipynb.j2 b/src/agentex/lib/cli/templates/sync-codex/dev.ipynb.j2 deleted file mode 100644 index b0691b1b1..000000000 --- a/src/agentex/lib/cli/templates/sync-codex/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_update)} object in streaming message.\")\n", - " elif isinstance(task_message_update, StreamTaskMessageFull):\n", - " content = task_message_update.content\n", - " if isinstance(content, TextContent):\n", - " print(content.content)\n", - " else:\n", - " print(f\"Found non-text {type(task_message_update)} object in full message.\")\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c5e7e042", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": ".venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.9" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/src/agentex/lib/cli/templates/sync-codex/environments.yaml.j2 b/src/agentex/lib/cli/templates/sync-codex/environments.yaml.j2 deleted file mode 100644 index 73924abdd..000000000 --- a/src/agentex/lib/cli/templates/sync-codex/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-codex/manifest.yaml.j2 b/src/agentex/lib/cli/templates/sync-codex/manifest.yaml.j2 deleted file mode 100644 index 4e3cc0c3a..000000000 --- a/src/agentex/lib/cli/templates/sync-codex/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: sync - # Unique name for your agent - # Used for task routing and monitoring - name: {{ agent_name }} - - # Description of what your agent does - # Helps with documentation and discovery - description: {{ description | tojson }} - - # Temporal workflow configuration - # Set enabled: true to use Temporal workflows for long-running tasks - temporal: - enabled: false - - # Optional: Credentials mapping - # Maps Kubernetes secrets to environment variables - # Common credentials include: - credentials: - # The codex CLI (`codex exec`) reads OPENAI_API_KEY directly; it does not - # use a LiteLLM key. - - env_var_name: OPENAI_API_KEY - secret_name: openai-api-key - secret_key: api-key - - env_var_name: SGP_API_KEY - secret_name: sgp-api-key - secret_key: api-key - - # Optional: Set Environment variables for running your agent locally as well - # as for deployment later on. OPENAI_API_KEY is supplied via the credential - # mapping above (deploy) or your local .env. Do NOT set it to an empty string - # here โ€” that would shadow the real key at runtime. - env: {} - # OPENAI_BASE_URL: "" - - -# Deployment Configuration -# ----------------------- -# Configuration for deploying your agent to Kubernetes clusters -deployment: - # Container image configuration - image: - repository: "" # Update with your container registry - tag: "latest" # Default tag, should be versioned in production - - imagePullSecrets: [] # Update with your image pull secret names - # - name: my-registry-secret - - # Global deployment settings that apply to all clusters - # These can be overridden in cluster-specific environments (environments.yaml) - global: - # Default replica count - replicaCount: 1 - - # Default resource requirements - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-codex/project/acp.py.j2 b/src/agentex/lib/cli/templates/sync-codex/project/acp.py.j2 deleted file mode 100644 index 0bc5d66a7..000000000 --- a/src/agentex/lib/cli/templates/sync-codex/project/acp.py.j2 +++ /dev/null @@ -1,185 +0,0 @@ -"""Sync ACP handler for {{ agent_name }} โ€” a Codex CLI harness agent. - -Demonstrates the ``convert_codex_to_agentex_events`` tap + ``CodexTurn`` + -``UnifiedEmitter`` for a sync (HTTP-yield) ACP agent. - -The handler: -1. Spawns ``codex exec --json`` as a LOCAL asyncio subprocess (no sandbox). - This is correct for local development; production isolation is a separate - concern. -2. Wraps the stdout line stream in a ``CodexTurn``. -3. Delivers every canonical ``StreamTaskMessage*`` event via - ``UnifiedEmitter.yield_turn``, which traces + yields each event back to - the HTTP caller in one pass. - -Live runs require: -- ``codex`` CLI on PATH (``npm install -g @openai/codex``) -- ``OPENAI_API_KEY`` set in the environment -""" - -from __future__ import annotations - -import os -import time -import codecs -import asyncio -from typing import AsyncGenerator -from collections.abc import AsyncIterator - -from dotenv import load_dotenv - -load_dotenv() - -import agentex.lib.adk as adk -from agentex.lib.adk import CodexTurn -from agentex.lib.types.acp import SendMessageParams -from agentex.lib.core.harness import UnifiedEmitter -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.types.text_content import TextContent -from agentex.lib.sdk.fastacp.fastacp import FastACP -from agentex.types.task_message_update import TaskMessageUpdate -from agentex.types.task_message_content import TaskMessageContent -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config - -logger = make_logger(__name__) - -add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SGP_API_KEY", ""), - sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - ) -) - -acp = FastACP.create(acp_type="sync") - -MODEL = os.environ.get("CODEX_MODEL", "o4-mini") - - -async def _spawn_codex(model: str) -> asyncio.subprocess.Process: - """Spawn ``codex exec --json`` locally and return the live process. - - Injection seam: tests replace this function with a fake that returns a - mock process whose stdout yields pre-recorded event lines. - - The flags: - --json machine-readable newline-delimited events - --skip-git-repo-check safe to run outside a git repo - --dangerously-bypass-approvals-and-sandbox - skip interactive approval prompts in a - non-interactive (server) context - --model which OpenAI model to use - - The caller writes the prompt to stdin after the process starts, then - closes stdin so codex knows input is complete. - """ - cmd = [ - "codex", - "exec", - "--json", - "--skip-git-repo-check", - "--dangerously-bypass-approvals-and-sandbox", - "--model", - model, - "-", # read prompt from stdin - ] - return await asyncio.create_subprocess_exec( - *cmd, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - # Discard stderr: codex --json writes events to stdout; its stderr is - # progress/debug noise. Capturing it with PIPE but never reading it - # would deadlock once codex fills the OS pipe buffer (~64 KB). - stderr=asyncio.subprocess.DEVNULL, - env={**os.environ}, - ) - - -async def _process_stdout(process: asyncio.subprocess.Process) -> AsyncIterator[str]: - """Yield newline-delimited JSON lines from the process stdout. - - Uses an incremental UTF-8 decoder so a multibyte character split across two - 4 KB reads is decoded correctly instead of being corrupted at the boundary. - """ - assert process.stdout is not None - decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") - buffer = "" - while True: - chunk = await process.stdout.read(4096) - if not chunk: - break - buffer += decoder.decode(chunk) - while "\n" in buffer: - line, buffer = buffer.split("\n", 1) - line = line.strip() - if line: - yield line - buffer += decoder.decode(b"", final=True) - if buffer.strip(): - yield buffer.strip() - - -@acp.on_message_send -async def handle_message_send( - params: SendMessageParams, -) -> TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]: - """Handle each message by running ``codex exec`` locally and streaming events.""" - task_id = params.task.id - content = params.content - if not isinstance(content, TextContent): - logger.warning("Ignoring non-text message content (type=%s)", getattr(content, "type", "?")) - return - user_message = content.content - logger.info("Processing message for task %s", task_id) - - start_ms = int(time.monotonic() * 1000) - - async with adk.tracing.span( - trace_id=task_id, - task_id=task_id, - name="message", - input={"message": user_message}, - data={"__span_type__": "AGENT_WORKFLOW"}, - ) as turn_span: - process = await _spawn_codex(MODEL) - - # Write prompt to stdin then close it so codex knows input is done. - assert process.stdin is not None - process.stdin.write(user_message.encode("utf-8")) - await process.stdin.drain() - process.stdin.close() - - turn = CodexTurn( - events=_process_stdout(process), - model=MODEL, - ) - - emitter = UnifiedEmitter( - task_id=task_id, - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - ) - - # Guarantee the subprocess is reaped even if the generator is abandoned - # (client disconnect / GC) or yield_turn raises; otherwise codex stays - # blocked writing to a full stdout pipe buffer and the process leaks. - try: - async for event in emitter.yield_turn(turn): - yield event - finally: - if process.returncode is None: - process.kill() - await process.wait() - - # Record the real wall-clock duration AFTER streaming completes; setting - # it before the stream ran would capture only subprocess spawn overhead. - turn.duration_ms = int(time.monotonic() * 1000) - start_ms - - if turn_span: - usage = turn.usage() - turn_span.output = { - "model": usage.model, - "input_tokens": usage.input_tokens, - "output_tokens": usage.output_tokens, - } diff --git a/src/agentex/lib/cli/templates/sync-codex/pyproject.toml.j2 b/src/agentex/lib/cli/templates/sync-codex/pyproject.toml.j2 deleted file mode 100644 index e499b1dc1..000000000 --- a/src/agentex/lib/cli/templates/sync-codex/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", - "python-dotenv>=1.0,<2", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "black", - "isort", - "flake8", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] - -[tool.black] -line-length = 88 -target-version = ['py312'] - -[tool.isort] -profile = "black" -line_length = 88 diff --git a/src/agentex/lib/cli/templates/sync-codex/requirements.txt.j2 b/src/agentex/lib/cli/templates/sync-codex/requirements.txt.j2 deleted file mode 100644 index 8c0630384..000000000 --- a/src/agentex/lib/cli/templates/sync-codex/requirements.txt.j2 +++ /dev/null @@ -1,8 +0,0 @@ -# Install agentex-sdk from local path -agentex-sdk - -# Scale GenAI Platform Python SDK -scale-gp - -# Loads .env files for local development -python-dotenv>=1.0,<2 diff --git a/src/agentex/lib/cli/templates/sync-langgraph/.dockerignore.j2 b/src/agentex/lib/cli/templates/sync-langgraph/.dockerignore.j2 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 dd3035f7b..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 ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-install-project --no-dev - -# Copy the project code -COPY {{ project_path_from_build_root }}/project ./project - -# Install the project -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-dev - -ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" -ENV PYTHONPATH=/app - -# Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-langgraph/Dockerfile.j2 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 33f2d7b67..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 | tojson }} - - # Temporal workflow configuration - # Set enabled: true to use Temporal workflows for long-running tasks - temporal: - enabled: false - - # Optional: Credentials mapping - # Maps Kubernetes secrets to environment variables - # Common credentials include: - credentials: - - env_var_name: LITELLM_API_KEY - secret_name: litellm-api-key - secret_key: api-key - - env_var_name: SGP_API_KEY - secret_name: sgp-api-key - secret_key: api-key - - # Optional: Set Environment variables for running your agent locally as well - # as for deployment later on - env: - LITELLM_API_KEY: "" # Set your LLM API key - # OPENAI_BASE_URL: "" - - -# Deployment Configuration -# ----------------------- -# Configuration for deploying your agent to Kubernetes clusters -deployment: - # Container image configuration - image: - repository: "" # Update with your container registry - tag: "latest" # Default tag, should be versioned in production - - imagePullSecrets: [] # Update with your image pull secret names - # - name: my-registry-secret - - # Global deployment settings that apply to all clusters - # These can be overridden in cluster-specific environments (environments.yaml) - global: - # Default replica count - replicaCount: 1 - - # Default resource requirements - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-langgraph/project/acp.py.j2 b/src/agentex/lib/cli/templates/sync-langgraph/project/acp.py.j2 deleted file mode 100644 index 32d261093..000000000 --- a/src/agentex/lib/cli/templates/sync-langgraph/project/acp.py.j2 +++ /dev/null @@ -1,103 +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.core.harness import UnifiedEmitter -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config -from agentex.lib.sdk.fastacp.fastacp import FastACP -from agentex.protocol.acp import SendMessageParams -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.types.text_content import TextContent -from agentex.lib.adk import LangGraphTurn -from agentex.types.task_message_content import TaskMessageContent -from agentex.types.task_message_delta import TextDelta -from agentex.types.task_message_update import TaskMessageUpdate -from dotenv import load_dotenv - -load_dotenv() -import os - -# LiteLLM proxy auth: copy LITELLM_API_KEY to OPENAI_API_KEY for OpenAI client compatibility -_litellm_key = os.environ.get("LITELLM_API_KEY") -if _litellm_key: - os.environ["OPENAI_API_KEY"] = _litellm_key - -from project.graph import create_graph - -logger = make_logger(__name__) - -# Register the Agentex tracing processor so spans are shipped to the backend -add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SGP_API_KEY", ""), - sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - )) - -# Create ACP server -acp = FastACP.create(acp_type="sync") - -# Compiled graph (lazy-initialized on first request) -_graph = None - - -async def get_graph(): - """Get or create the compiled graph instance.""" - global _graph - if _graph is None: - _graph = await create_graph() - return _graph - - -@acp.on_message_send -async def handle_message_send( - params: SendMessageParams, -) -> TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]: - """Handle incoming messages from Agentex, streaming tokens and tool calls.""" - graph = await get_graph() - - thread_id = params.task.id - content = params.content - if not isinstance(content, TextContent): - logger.warning("Ignoring non-text message content (type=%s)", getattr(content, "type", "?")) - return - user_message = content.content - - logger.info(f"Processing message for thread {thread_id}") - - async with adk.tracing.span( - trace_id=thread_id, - name="message", - input={"message": user_message}, - data={"__span_type__": "AGENT_WORKFLOW"}, - ) as turn_span: - stream = graph.astream( - {"messages": [{"role": "user", "content": user_message}]}, - config={"configurable": {"thread_id": thread_id}}, - stream_mode=["messages", "updates"], - ) - - turn = LangGraphTurn(stream, model=None) - emitter = UnifiedEmitter( - task_id=thread_id, - trace_id=thread_id, - parent_span_id=turn_span.id if turn_span else None, - ) - - final_text = "" - async for event in emitter.yield_turn(turn): - # Accumulate text deltas for span output - delta = getattr(event, "delta", None) - if isinstance(delta, TextDelta) and delta.text_delta: - final_text += delta.text_delta - yield event - - if turn_span: - turn_span.output = {"final_output": final_text} diff --git a/src/agentex/lib/cli/templates/sync-langgraph/project/graph.py.j2 b/src/agentex/lib/cli/templates/sync-langgraph/project/graph.py.j2 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 dd3035f7b..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 ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-install-project --no-dev - -# Copy the project code -COPY {{ project_path_from_build_root }}/project ./project - -# Install the project -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-dev - -ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" -ENV PYTHONPATH=/app - -# Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile.j2 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 c49f0f56f..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/README.md.j2 +++ /dev/null @@ -1,327 +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 - content = params.content - if not isinstance(content, TextContent): - return TextContent(author="agent", content="Sorry, I can only handle text messages right now.") - user_message = content.content - - # Generate response - response = await generate_intelligent_response(user_message) - - return TextContent( - author=MessageAuthor.AGENT, - content=response - ) -``` - -### Integration with External Services -{% if use_uv %} -```bash -# Add service clients -agentex uv add httpx requests-oauthlib - -# Add AI/ML libraries -agentex uv add openai anthropic transformers - -# Add fast processing libraries -agentex uv add numpy pandas -``` -{% else %} -```bash -# Add to requirements.txt -echo "httpx" >> requirements.txt -echo "openai" >> requirements.txt -echo "numpy" >> requirements.txt -pip install -r requirements.txt -``` -{% endif %} - -## Troubleshooting - -### Common Issues - -1. **Agent not appearing in web UI** - - Check if agent is running on port 8000 - - Verify `ENVIRONMENT=development` is set - - Check agent logs for errors - -2. **Slow response times** - - Profile your message handling code - - Consider caching expensive operations - - Optimize database queries and API calls - -3. **Dependency issues** -{% if use_uv %} - - Run `agentex uv sync` to ensure all dependencies are installed -{% else %} - - Run `pip install -r requirements.txt` - - Check if all dependencies are correctly listed in requirements.txt -{% endif %} - -4. **Port conflicts** - - Check if another service is using port 8000 - - Use `lsof -i :8000` to find conflicting processes - -Happy building with Sync ACP! ๐Ÿš€โšก \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/dev.ipynb.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/dev.ipynb.j2 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 6377d01cd..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 | tojson }} - - # Temporal workflow configuration - # Set enabled: true to use Temporal workflows for long-running tasks - temporal: - enabled: false - - # Optional: Credentials mapping - # Maps Kubernetes secrets to environment variables - # Common credentials include: - credentials: [] # Update with your credentials - # - env_var_name: LITELLM_API_KEY - # secret_name: litellm-api-key - # secret_key: api-key - - # Optional: Set Environment variables for running your agent locally as well - # as for deployment later on - env: - # Disable the OpenAI Agents SDK's native tracer (it would otherwise try to - # ship traces to api.openai.com and 401 behind a LiteLLM/proxy key). - OPENAI_AGENTS_DISABLE_TRACING: "1" - # LITELLM_API_KEY: "" - # OPENAI_BASE_URL: "" - # OPENAI_ORG_ID: "" - - -# Deployment Configuration -# ----------------------- -# Configuration for deploying your agent to Kubernetes clusters -deployment: - # Container image configuration - image: - repository: "" # Update with your container registry - tag: "latest" # Default tag, should be versioned in production - - imagePullSecrets: [] # Update with your image pull secret names - # - name: my-registry-secret - - # Global deployment settings that apply to all clusters - # These can be overridden in cluster-specific environments (environments.yaml) - global: - # Default replica count - replicaCount: 1 - - # Default resource requirements - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/acp.py.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/acp.py.j2 deleted file mode 100644 index 14af98351..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/acp.py.j2 +++ /dev/null @@ -1,84 +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 - content = params.content - if not isinstance(content, TextContent): - logger.warning("Ignoring non-text message content (type=%s)", getattr(content, "type", "?")) - return TextContent(author="agent", content="Sorry, I can only handle text messages right now.") - user_message = content.content - logger.info(f"Processing message for task {task_id}") - - async with adk.tracing.span( - trace_id=task_id, - task_id=task_id, - name="message", - input={"message": user_message}, - data={"__span_type__": "AGENT_WORKFLOW"}, - ) as turn_span: - final_output = await run_agent(user_message) - if turn_span: - turn_span.output = {"final_output": final_output} - - return TextContent(author="agent", content=final_output) diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/agent.py.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/agent.py.j2 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 dd3035f7b..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 ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-install-project --no-dev - -# Copy the project code -COPY {{ project_path_from_build_root }}/project ./project - -# Install the project -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-dev - -ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" -ENV PYTHONPATH=/app - -# Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-openai-agents/Dockerfile.j2 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 7711969cd..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents/README.md.j2 +++ /dev/null @@ -1,316 +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 - content = params.content - if not isinstance(content, TextContent): - return TextContent(author="agent", content="Sorry, I can only handle text messages right now.") - user_message = content.content - - # Generate response - response = await generate_intelligent_response(user_message) - - return TextContent( - author=MessageAuthor.AGENT, - content=response - ) -``` - -### Integration with External Services -{% if use_uv %} -```bash -# Add service clients -agentex uv add httpx requests-oauthlib - -# Add AI/ML libraries -agentex uv add openai anthropic transformers - -# Add fast processing libraries -agentex uv add numpy pandas -``` -{% else %} -```bash -# Add to requirements.txt -echo "httpx" >> requirements.txt -echo "openai" >> requirements.txt -echo "numpy" >> requirements.txt -pip install -r requirements.txt -``` -{% endif %} - -## Troubleshooting - -### Common Issues - -1. **Agent not appearing in web UI** - - Check if agent is running on port 8000 - - Verify `ENVIRONMENT=development` is set - - Check agent logs for errors - -2. **Slow response times** - - Profile your message handling code - - Consider caching expensive operations - - Optimize database queries and API calls - -3. **Dependency issues** -{% if use_uv %} - - Run `agentex uv sync` to ensure all dependencies are installed -{% else %} - - Run `pip install -r requirements.txt` - - Check if all dependencies are correctly listed in requirements.txt -{% endif %} - -4. **Port conflicts** - - Check if another service is using port 8000 - - Use `lsof -i :8000` to find conflicting processes - -Happy building with Sync ACP! ๐Ÿš€โšก \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-openai-agents/dev.ipynb.j2 b/src/agentex/lib/cli/templates/sync-openai-agents/dev.ipynb.j2 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 875fcc5e0..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 | tojson }} - - # Temporal workflow configuration - # Set enabled: true to use Temporal workflows for long-running tasks - temporal: - enabled: false - - # Optional: Credentials mapping - # Maps Kubernetes secrets to environment variables - # Common credentials include: - credentials: [] # Update with your credentials - # - env_var_name: LITELLM_API_KEY - # secret_name: litellm-api-key - # secret_key: api-key - - # Optional: Set Environment variables for running your agent locally as well - # as for deployment later on - env: {} # Update with your environment variables - # LITELLM_API_KEY: "" - # OPENAI_BASE_URL: "" - # OPENAI_ORG_ID: "" - - -# Deployment Configuration -# ----------------------- -# Configuration for deploying your agent to Kubernetes clusters -deployment: - # Container image configuration - image: - repository: "" # Update with your container registry - tag: "latest" # Default tag, should be versioned in production - - imagePullSecrets: [] # Update with your image pull secret names - # - name: my-registry-secret - - # Global deployment settings that apply to all clusters - # These can be overridden in cluster-specific environments (environments.yaml) - global: - # Default replica count - replicaCount: 1 - - # Default resource requirements - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-openai-agents/project/acp.py.j2 b/src/agentex/lib/cli/templates/sync-openai-agents/project/acp.py.j2 deleted file mode 100644 index 41029f2ce..000000000 --- a/src/agentex/lib/cli/templates/sync-openai-agents/project/acp.py.j2 +++ /dev/null @@ -1,156 +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 - - content = params.content - if not isinstance(content, TextContent): - logger.warning("Ignoring non-text message content (type=%s)", getattr(content, "type", "?")) - return - - user_prompt = content.content - - # Retrieve the task state. Each event is handled as a new turn, so we need to get the state for the current turn. - task_state = await adk.state.get_by_task_and_agent(task_id=params.task.id, agent_id=params.agent.id) - if not task_state: - # If the state doesn't exist, create it. - state = StateModel(input_list=[], turn_number=0) - task_state = await adk.state.create(task_id=params.task.id, agent_id=params.agent.id, state=state) - else: - state = StateModel.model_validate(task_state.state) - - state.turn_number += 1 - state.input_list.append({"role": "user", "content": user_prompt}) - - # Initialize the sync provider and run config to allow for tracing - provider = SyncStreamingProvider( - trace_id=params.task.id, - ) - - run_config = RunConfig( - model_provider=provider, - ) - - # Initialize the agent - agent = Agent( - name=AGENT_NAME, - instructions=SYSTEM_PROMPT, - model=MODEL, - tools=[get_weather], - ) - - # Run the agent with the conversation history from state - result = Runner.run_streamed( - agent, - state.input_list, - run_config=run_config - ) - - # Convert the OpenAI events to Agentex events and stream them back to the client - async for agentex_event in convert_openai_to_agentex_events(result.stream_events()): - yield agentex_event - - # After streaming is complete, update state with the full conversation history - state.input_list = result.to_input_list() - await adk.state.update( - state_id=task_state.id, - task_id=params.task.id, - agent_id=params.agent.id, - state=state, - trace_id=params.task.id, - ) \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-openai-agents/pyproject.toml.j2 b/src/agentex/lib/cli/templates/sync-openai-agents/pyproject.toml.j2 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 dd3035f7b..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 ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-install-project --no-dev - -# Copy the project code -COPY {{ project_path_from_build_root }}/project ./project - -# Install the project -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-dev - -ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" -ENV PYTHONPATH=/app - -# Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/Dockerfile.j2 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 7711969cd..000000000 --- a/src/agentex/lib/cli/templates/sync-pydantic-ai/README.md.j2 +++ /dev/null @@ -1,316 +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 - content = params.content - if not isinstance(content, TextContent): - return TextContent(author="agent", content="Sorry, I can only handle text messages right now.") - user_message = content.content - - # Generate response - response = await generate_intelligent_response(user_message) - - return TextContent( - author=MessageAuthor.AGENT, - content=response - ) -``` - -### Integration with External Services -{% if use_uv %} -```bash -# Add service clients -agentex uv add httpx requests-oauthlib - -# Add AI/ML libraries -agentex uv add openai anthropic transformers - -# Add fast processing libraries -agentex uv add numpy pandas -``` -{% else %} -```bash -# Add to requirements.txt -echo "httpx" >> requirements.txt -echo "openai" >> requirements.txt -echo "numpy" >> requirements.txt -pip install -r requirements.txt -``` -{% endif %} - -## Troubleshooting - -### Common Issues - -1. **Agent not appearing in web UI** - - Check if agent is running on port 8000 - - Verify `ENVIRONMENT=development` is set - - Check agent logs for errors - -2. **Slow response times** - - Profile your message handling code - - Consider caching expensive operations - - Optimize database queries and API calls - -3. **Dependency issues** -{% if use_uv %} - - Run `agentex uv sync` to ensure all dependencies are installed -{% else %} - - Run `pip install -r requirements.txt` - - Check if all dependencies are correctly listed in requirements.txt -{% endif %} - -4. **Port conflicts** - - Check if another service is using port 8000 - - Use `lsof -i :8000` to find conflicting processes - -Happy building with Sync ACP! ๐Ÿš€โšก \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/dev.ipynb.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/dev.ipynb.j2 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 875fcc5e0..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 | tojson }} - - # Temporal workflow configuration - # Set enabled: true to use Temporal workflows for long-running tasks - temporal: - enabled: false - - # Optional: Credentials mapping - # Maps Kubernetes secrets to environment variables - # Common credentials include: - credentials: [] # Update with your credentials - # - env_var_name: LITELLM_API_KEY - # secret_name: litellm-api-key - # secret_key: api-key - - # Optional: Set Environment variables for running your agent locally as well - # as for deployment later on - env: {} # Update with your environment variables - # LITELLM_API_KEY: "" - # OPENAI_BASE_URL: "" - # OPENAI_ORG_ID: "" - - -# Deployment Configuration -# ----------------------- -# Configuration for deploying your agent to Kubernetes clusters -deployment: - # Container image configuration - image: - repository: "" # Update with your container registry - tag: "latest" # Default tag, should be versioned in production - - imagePullSecrets: [] # Update with your image pull secret names - # - name: my-registry-secret - - # Global deployment settings that apply to all clusters - # These can be overridden in cluster-specific environments (environments.yaml) - global: - # Default replica count - replicaCount: 1 - - # Default resource requirements - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/project/acp.py.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/project/acp.py.j2 deleted file mode 100644 index 1a3c6f0a9..000000000 --- a/src/agentex/lib/cli/templates/sync-pydantic-ai/project/acp.py.j2 +++ /dev/null @@ -1,98 +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 MODEL_NAME, create_agent - -import agentex.lib.adk as adk -from agentex.protocol.acp import SendMessageParams -from agentex.lib.core.harness import UnifiedEmitter -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.types.text_content import TextContent -from agentex.lib.sdk.fastacp.fastacp import FastACP -from agentex.types.task_message_update import TaskMessageUpdate -from agentex.types.task_message_content import TaskMessageContent -from agentex.lib.adk import PydanticAITurn -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config - -logger = make_logger(__name__) - -# Register the SGP tracing exporter. Spans also reach the AgentEx backend -# (and surface in the per-task spans dropdown) via the default Agentex -# processor that's lazy-initialised on first span. -SGP_API_KEY = os.environ.get("SGP_API_KEY", "") -SGP_ACCOUNT_ID = os.environ.get("SGP_ACCOUNT_ID", "") -if SGP_API_KEY and SGP_ACCOUNT_ID: - add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=SGP_API_KEY, - sgp_account_id=SGP_ACCOUNT_ID, - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - ) - ) - -acp = FastACP.create(acp_type="sync") - -# Lazy-initialised agent instance so the Pydantic AI Agent is constructed -# inside the running event loop on the first request, not at import time. -_agent = None - - -def get_agent(): - """Return the cached Pydantic AI agent, creating it on first use.""" - global _agent - if _agent is None: - _agent = create_agent() - return _agent - - -@acp.on_message_send -async def handle_message_send( - params: SendMessageParams, -) -> TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]: - """Handle each incoming user message, streaming tokens and tool calls back.""" - agent = get_agent() - task_id = params.task.id - - content = params.content - if not isinstance(content, TextContent): - logger.warning("Ignoring non-text message content (type=%s)", getattr(content, "type", "?")) - return - - user_message = content.content - logger.info(f"Processing message for task {task_id}") - - # Open a per-message turn span. Tool calls below nest underneath this - # span via the emitter's parent_span_id wiring. - async with adk.tracing.span( - trace_id=task_id, - task_id=task_id, - name="message", - input={"message": user_message}, - data={"__span_type__": "AGENT_WORKFLOW"}, - ) as turn_span: - # Construct the UnifiedEmitter from the ACP/streaming context so tracing - # is automatic: tool spans nest under this turn's span. - emitter = UnifiedEmitter( - task_id=task_id, - trace_id=task_id, - parent_span_id=turn_span.id if turn_span else None, - ) - async with agent.run_stream_events(user_message) as stream: - turn = PydanticAITurn(stream, model=MODEL_NAME) - async for ev in emitter.yield_turn(turn): - yield ev diff --git a/src/agentex/lib/cli/templates/sync-pydantic-ai/project/agent.py.j2 b/src/agentex/lib/cli/templates/sync-pydantic-ai/project/agent.py.j2 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 dd3035f7b..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 ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-install-project --no-dev - -# Copy the project code -COPY {{ project_path_from_build_root }}/project ./project - -# Install the project -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-dev - -ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" -ENV PYTHONPATH=/app - -# Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync/Dockerfile.j2 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 7711969cd..000000000 --- a/src/agentex/lib/cli/templates/sync/README.md.j2 +++ /dev/null @@ -1,316 +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 - content = params.content - if not isinstance(content, TextContent): - return TextContent(author="agent", content="Sorry, I can only handle text messages right now.") - user_message = content.content - - # Generate response - response = await generate_intelligent_response(user_message) - - return TextContent( - author=MessageAuthor.AGENT, - content=response - ) -``` - -### Integration with External Services -{% if use_uv %} -```bash -# Add service clients -agentex uv add httpx requests-oauthlib - -# Add AI/ML libraries -agentex uv add openai anthropic transformers - -# Add fast processing libraries -agentex uv add numpy pandas -``` -{% else %} -```bash -# Add to requirements.txt -echo "httpx" >> requirements.txt -echo "openai" >> requirements.txt -echo "numpy" >> requirements.txt -pip install -r requirements.txt -``` -{% endif %} - -## Troubleshooting - -### Common Issues - -1. **Agent not appearing in web UI** - - Check if agent is running on port 8000 - - Verify `ENVIRONMENT=development` is set - - Check agent logs for errors - -2. **Slow response times** - - Profile your message handling code - - Consider caching expensive operations - - Optimize database queries and API calls - -3. **Dependency issues** -{% if use_uv %} - - Run `agentex uv sync` to ensure all dependencies are installed -{% else %} - - Run `pip install -r requirements.txt` - - Check if all dependencies are correctly listed in requirements.txt -{% endif %} - -4. **Port conflicts** - - Check if another service is using port 8000 - - Use `lsof -i :8000` to find conflicting processes - -Happy building with Sync ACP! ๐Ÿš€โšก \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync/dev.ipynb.j2 b/src/agentex/lib/cli/templates/sync/dev.ipynb.j2 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 875fcc5e0..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 | tojson }} - - # Temporal workflow configuration - # Set enabled: true to use Temporal workflows for long-running tasks - temporal: - enabled: false - - # Optional: Credentials mapping - # Maps Kubernetes secrets to environment variables - # Common credentials include: - credentials: [] # Update with your credentials - # - env_var_name: LITELLM_API_KEY - # secret_name: litellm-api-key - # secret_key: api-key - - # Optional: Set Environment variables for running your agent locally as well - # as for deployment later on - env: {} # Update with your environment variables - # LITELLM_API_KEY: "" - # OPENAI_BASE_URL: "" - # OPENAI_ORG_ID: "" - - -# Deployment Configuration -# ----------------------- -# Configuration for deploying your agent to Kubernetes clusters -deployment: - # Container image configuration - image: - repository: "" # Update with your container registry - tag: "latest" # Default tag, should be versioned in production - - imagePullSecrets: [] # Update with your image pull secret names - # - name: my-registry-secret - - # Global deployment settings that apply to all clusters - # These can be overridden in cluster-specific environments (environments.yaml) - global: - # Default replica count - replicaCount: 1 - - # Default resource requirements - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync/project/acp.py.j2 b/src/agentex/lib/cli/templates/sync/project/acp.py.j2 deleted file mode 100644 index d7d6f51d2..000000000 --- a/src/agentex/lib/cli/templates/sync/project/acp.py.j2 +++ /dev/null @@ -1,32 +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""" - content = params.content - if not isinstance(content, TextContent): - return TextContent( - author="agent", - content="Sorry, I can only handle text messages right now.", - ) - return TextContent( - author="agent", - content=f"Hello! I've received your message. Here's a generic response, but in future tutorials we'll see how you can get me to intelligently respond to your message. This is what I heard you say: {content.content}", - ) \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync/pyproject.toml.j2 b/src/agentex/lib/cli/templates/sync/pyproject.toml.j2 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-claude-code/.dockerignore.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/.dockerignore.j2 deleted file mode 100644 index c2d7fca4d..000000000 --- a/src/agentex/lib/cli/templates/temporal-claude-code/.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-claude-code/.env.example.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/.env.example.j2 deleted file mode 100644 index 5aff34a60..000000000 --- a/src/agentex/lib/cli/templates/temporal-claude-code/.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 the Claude Code CLI (the `claude` subprocess this agent spawns) -ANTHROPIC_API_KEY= - -# LLM base URL (optional - override to use a different provider) -# OPENAI_BASE_URL= - -# SGP Configuration (optional - for tracing) -# SGP_API_KEY= -# SGP_ACCOUNT_ID= -# SGP_CLIENT_BASE_URL= diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 deleted file mode 100644 index f8746c573..000000000 --- a/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 +++ /dev/null @@ -1,61 +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 the Claude Code CLI: the activity shells out to `claude` on every -# turn, so the binary must be present in the runtime image. -RUN npm install -g @anthropic-ai/claude-code - -# Install tctl (Temporal CLI) -RUN ARCH="$(uname -m)" && \ - case "$ARCH" in x86_64) TCTL_ARCH=amd64 ;; aarch64|arm64) TCTL_ARCH=arm64 ;; *) TCTL_ARCH=amd64 ;; esac && \ - curl -L "https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_${TCTL_ARCH}.tar.gz" -o /tmp/tctl.tar.gz && \ - tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ - chmod +x /usr/local/bin/tctl && \ - rm /tmp/tctl.tar.gz - -ENV UV_COMPILE_BYTECODE=1 -ENV UV_LINK_MODE=copy -ENV UV_HTTP_TIMEOUT=1000 - -WORKDIR /app/{{ project_path_from_build_root }} - -# Copy dependency files for layer caching -COPY {{ project_path_from_build_root }}/pyproject.toml ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-install-project --no-dev - -# Copy the project code -COPY {{ project_path_from_build_root }}/project ./project - -# Install the project -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-dev - -ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" - -# Run the ACP server using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] - -# When we deploy the worker, we will replace the CMD with the following -# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 deleted file mode 100644 index 225863607..000000000 --- a/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 +++ /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 the Claude Code CLI: the activity shells out to `claude` on every -# turn, so the binary must be present in the runtime image. -RUN npm install -g @anthropic-ai/claude-code - -# Install tctl (Temporal CLI) -RUN ARCH="$(uname -m)" && \ - case "$ARCH" in x86_64) TCTL_ARCH=amd64 ;; aarch64|arm64) TCTL_ARCH=arm64 ;; *) TCTL_ARCH=amd64 ;; esac && \ - curl -L "https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_${TCTL_ARCH}.tar.gz" -o /tmp/tctl.tar.gz && \ - tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ - chmod +x /usr/local/bin/tctl && \ - rm /tmp/tctl.tar.gz - -RUN uv pip install --system --upgrade pip setuptools wheel - -ENV UV_HTTP_TIMEOUT=1000 - -# Copy just the requirements file to optimize caching -COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt - -WORKDIR /app/{{ project_path_from_build_root }} - -# Install the required Python packages -RUN uv pip install --system -r requirements.txt - -# Copy the project code -COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project - -# Run the ACP server using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] - -# When we deploy the worker, we will replace the CMD with the following -# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/README.md.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/README.md.j2 deleted file mode 100644 index 35ac019b5..000000000 --- a/src/agentex/lib/cli/templates/temporal-claude-code/README.md.j2 +++ /dev/null @@ -1,73 +0,0 @@ -# {{ agent_name }} โ€” AgentEx Temporal + Claude Code - -This template builds a **Temporal-durable** agent that drives the **Claude Code -CLI** through the unified harness surface on AgentEx: -- A Temporal workflow holds conversation state (the Claude Code `session_id`) - durably across worker crashes -- Each turn delegates to the `run_claude_code_turn` activity, which spawns the - CLI (subprocess I/O is not permitted on the workflow event loop) -- The activity wraps the CLI's stdout stream in a `ClaudeCodeTurn` and delivers - canonical `StreamTaskMessage*` events via `UnifiedEmitter.auto_send_turn` -- Tracing integration to SGP / AgentEx - -## Prerequisites - -- The `claude` CLI installed and on your `PATH` -- An `ANTHROPIC_API_KEY` (or equivalent credential) in your environment -- A running Temporal service (provided automatically by the local dev stack) - -## Running the Agent - -```bash -agentex agents run --manifest manifest.yaml -``` - -This starts both the ACP HTTP server and the Temporal worker. - -## Project Structure - -``` -{{ project_name }}/ -โ”œโ”€โ”€ project/ -โ”‚ โ”œโ”€โ”€ __init__.py -โ”‚ โ”œโ”€โ”€ acp.py # Thin ACP server; FastACP auto-wires to the workflow -โ”‚ โ”œโ”€โ”€ workflow.py # Temporal workflow (durable conversation state) -โ”‚ โ”œโ”€โ”€ activities.py # run_claude_code_turn activity (CLI subprocess) -โ”‚ โ””โ”€โ”€ run_worker.py # Temporal worker entrypoint -โ”œโ”€โ”€ Dockerfile -โ”œโ”€โ”€ manifest.yaml -โ”œโ”€โ”€ dev.ipynb -{% if use_uv %} -โ””โ”€โ”€ pyproject.toml -{% else %} -โ””โ”€โ”€ requirements.txt -{% endif %} -``` - -## Key Concepts - -### Subprocess must run in an activity -Temporal runs workflow + signal-handler bodies on a deterministic sandbox event -loop that does not implement `subprocess_exec`. The workflow therefore delegates -each turn to the `run_claude_code_turn` activity, which also gains Temporal's -retry + timeout guarantees. - -### The unified harness surface -`ClaudeCodeTurn` + `UnifiedEmitter` are the unified harness surface. The turn -normalizes CLI output into canonical AgentEx events; the emitter traces and -delivers them. - -## Development - -### 1. Customize the subprocess -Edit `_spawn_claude` in `project/activities.py` to change the CLI flags, working -directory, or how the prompt is delivered. - -### 2. Configure Credentials -Set your credentials via `manifest.yaml`, an exported environment variable, or a -`.env` file in the project directory. - -### 3. Run Locally -```bash -export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml -``` diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/dev.ipynb.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/dev.ipynb.j2 deleted file mode 100644 index d3a68303f..000000000 --- a/src/agentex/lib/cli/templates/temporal-claude-code/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-claude-code/environments.yaml.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/environments.yaml.j2 deleted file mode 100644 index a3df5e228..000000000 --- a/src/agentex/lib/cli/templates/temporal-claude-code/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-claude-code/manifest.yaml.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/manifest.yaml.j2 deleted file mode 100644 index 9aa2b2b2f..000000000 --- a/src/agentex/lib/cli/templates/temporal-claude-code/manifest.yaml.j2 +++ /dev/null @@ -1,142 +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 | tojson }} - - # Temporal workflow configuration - # This enables your agent to run as a Temporal workflow for long-running tasks - temporal: - enabled: true - workflows: - # Name of the workflow class - # Must match the @workflow.defn name in your workflow.py - - name: {{ workflow_name }} - - # Queue name for task distribution - # Used by Temporal to route tasks to your agent - # Convention: _task_queue - queue_name: {{ queue_name }} - - # Optional: Health check port for temporal worker - # Defaults to 80 if not specified - # health_check_port: 80 - - # Optional: Credentials mapping - # Maps Kubernetes secrets to environment variables - # Common credentials include: - credentials: - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - # The Claude Code CLI spawned in project/activities.py authenticates with - # ANTHROPIC_API_KEY; without it every turn fails with a CLI auth error. - - env_var_name: ANTHROPIC_API_KEY - secret_name: anthropic-api-key - secret_key: api-key - - # Optional: Set Environment variables for running your agent locally as well - # as for deployment later on. ANTHROPIC_API_KEY is supplied via the credential - # mapping above (deploy) or your local .env (load_dotenv). Do NOT set it to an - # empty string here โ€” that would shadow the real key at runtime. - env: {} - # ANTHROPIC_API_KEY: "" # uncomment only to hardcode for local runs - - -# Deployment Configuration -# ----------------------- -# Configuration for deploying your agent to Kubernetes clusters -deployment: - # Container image configuration - image: - repository: "" # Update with your container registry - tag: "latest" # Default tag, should be versioned in production - - imagePullSecrets: [] # Update with your image pull secret name - # - name: my-registry-secret - - # Global deployment settings that apply to all clusters - # These can be overridden in cluster-specific environments (environments.yaml) - global: - # Default replica count - replicaCount: 1 - - # Default resource requirements - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/project/acp.py.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/project/acp.py.j2 deleted file mode 100644 index 0515efeeb..000000000 --- a/src/agentex/lib/cli/templates/temporal-claude-code/project/acp.py.j2 +++ /dev/null @@ -1,31 +0,0 @@ -"""ACP server for {{ agent_name }} โ€” a Temporal Claude Code agent. - -This file is intentionally thin. When ``acp_type="async"`` is combined -with ``TemporalACPConfig``, FastACP auto-wires: - - HTTP task/create -> @workflow.run on the workflow class - HTTP task/event/send -> @workflow.signal(SignalName.RECEIVE_EVENT) - HTTP task/cancel -> workflow cancellation via the Temporal client - -The actual agent code lives in ``project/workflow.py`` and is executed by -the Temporal worker (``project/run_worker.py``), not by this HTTP process. -""" - -from __future__ import annotations - -import os - -from dotenv import load_dotenv - -load_dotenv() - -from agentex.lib.types.fastacp import TemporalACPConfig -from agentex.lib.sdk.fastacp.fastacp import FastACP - -acp = FastACP.create( - acp_type="async", - config=TemporalACPConfig( - type="temporal", - temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), - ), -) diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/project/activities.py.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/project/activities.py.j2 deleted file mode 100644 index 94055c7df..000000000 --- a/src/agentex/lib/cli/templates/temporal-claude-code/project/activities.py.j2 +++ /dev/null @@ -1,155 +0,0 @@ -"""Temporal activity for {{ agent_name }} โ€” Claude Code harness. - -Subprocess spawning (and any other I/O) must run inside a Temporal *activity*, -not in workflow code. Temporal runs workflow + signal-handler bodies on a -deterministic sandbox event loop that does not implement ``subprocess_exec`` -(or threads / sockets), so spawning the CLI directly in the signal handler -raises ``NotImplementedError``. This activity runs the Claude Code CLI, drives -the ``ClaudeCodeTurn`` through ``UnifiedEmitter.auto_send_turn`` (the async -Redis push path), and returns the turn result to the workflow. - -The ``_spawn_claude`` async generator is an injectable seam: offline tests -can provide a fake that yields pre-recorded stdout lines so no real CLI runs. -""" - -from __future__ import annotations - -import asyncio -from typing import Any, AsyncIterator -from datetime import datetime -from collections import deque - -from temporalio import activity - -from agentex.lib.adk import ClaudeCodeTurn -from agentex.lib.core.harness import UnifiedEmitter -from agentex.lib.utils.logging import make_logger -from agentex.lib.utils.model_utils import BaseModel - -logger = make_logger(__name__) - -RUN_CLAUDE_CODE_TURN_ACTIVITY = "run_claude_code_turn" - - -class RunClaudeCodeTurnParams(BaseModel): - """Arguments for one Claude Code turn run inside an activity.""" - - task_id: str - prompt: str - trace_id: str | None = None - parent_span_id: str | None = None - session_id: str | None = None - created_at: datetime | None = None - - -class RunClaudeCodeTurnResult(BaseModel): - """Result returned from the activity to the workflow.""" - - final_text: str - session_id: str | None = None - - -async def _spawn_claude(prompt: str, session_id: str | None = None) -> AsyncIterator[str]: - """Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines. - - Pass ``session_id`` to resume a previous Claude Code session (multi-turn - memory via ``-r ``). - - Injectable seam: tests can monkeypatch this with a fake async iterator so no - real CLI invocation is needed offline. - """ - cmd = [ - "claude", - "-p", - "--output-format", - "stream-json", - "--verbose", - ] - if session_id: - cmd.extend(["-r", session_id]) - - proc = await asyncio.create_subprocess_exec( - *cmd, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - assert proc.stdout is not None - assert proc.stdin is not None - - proc.stdin.write(prompt.encode()) - await proc.stdin.drain() - proc.stdin.close() - - # Drain stderr concurrently. With --verbose, Claude Code can write enough to - # stderr to fill the OS pipe buffer; if we only read stdout, the CLI blocks - # on its stderr write while we block reading stdout โ€” a deadlock. A - # background task keeps stderr flowing so stdout never stalls. We keep a - # bounded tail so a non-zero exit can be surfaced with context instead of - # silently completing the turn. - stderr_tail: deque[str] = deque(maxlen=20) - - async def _drain_stderr() -> None: - assert proc.stderr is not None - async for raw in proc.stderr: - text = raw.decode("utf-8", errors="replace").rstrip() - if text: - stderr_tail.append(text) - - stderr_task = asyncio.create_task(_drain_stderr()) - - try: - buffer = "" - async for chunk in proc.stdout: - buffer += chunk.decode("utf-8", errors="replace") - while "\n" in buffer: - line, buffer = buffer.split("\n", 1) - line = line.strip() - if line: - yield line - - if buffer.strip(): - yield buffer.strip() - - await proc.wait() - if proc.returncode: - # The CLI failed (missing binary/auth, bad command). Raise so the - # activity (and turn) surfaces as failed instead of completing with - # no output. Temporal will apply the activity's retry policy. - tail = "\n".join(stderr_tail) - raise RuntimeError( - f"claude CLI exited with status {proc.returncode}:\n{tail}" - ) - finally: - # Release the subprocess and stderr drain task even if the consumer - # abandons the generator early (task cancellation / client disconnect): - # cancel the drain task and terminate+reap the process if it is still - # running, so neither is leaked. - stderr_task.cancel() - try: - await stderr_task - except asyncio.CancelledError: - pass - if proc.returncode is None: - try: - proc.terminate() - except ProcessLookupError: - pass - await proc.wait() - - -@activity.defn(name=RUN_CLAUDE_CODE_TURN_ACTIVITY) -async def run_claude_code_turn(params: RunClaudeCodeTurnParams) -> dict[str, Any]: - """Run one Claude Code turn end-to-end and stream events to the task. - - Runs in an activity (real asyncio loop) so subprocess I/O is permitted. - """ - emitter = UnifiedEmitter( - task_id=params.task_id, - trace_id=params.trace_id, - parent_span_id=params.parent_span_id, - ) - turn = ClaudeCodeTurn(_spawn_claude(params.prompt, session_id=params.session_id)) - result = await emitter.auto_send_turn(turn, created_at=params.created_at) - - return RunClaudeCodeTurnResult(final_text=result.final_text, session_id=turn.session_id).model_dump() diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/project/run_worker.py.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/project/run_worker.py.j2 deleted file mode 100644 index 354326b9d..000000000 --- a/src/agentex/lib/cli/templates/temporal-claude-code/project/run_worker.py.j2 +++ /dev/null @@ -1,41 +0,0 @@ -"""Temporal worker for {{ agent_name }} โ€” Claude Code harness. - -Run as a separate long-lived process alongside the ACP HTTP server. The -worker polls Temporal for workflow + activity tasks and executes them. - -The Claude Code CLI subprocess runs in the ``run_claude_code_turn`` activity -(registered below alongside the built-in Agentex activities), because -subprocess I/O is not permitted on the Temporal workflow event loop. -""" - -import asyncio - -from project.workflow import {{ workflow_class }} -from project.activities import run_claude_code_turn -from agentex.lib.utils.debug import setup_debug_if_enabled -from agentex.lib.utils.logging import make_logger -from agentex.lib.environment_variables import EnvironmentVariables -from agentex.lib.core.temporal.activities import get_all_activities -from agentex.lib.core.temporal.workers.worker import AgentexWorker - -environment_variables = EnvironmentVariables.refresh() -logger = make_logger(__name__) - - -async def main(): - setup_debug_if_enabled() - - task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE - if task_queue_name is None: - raise ValueError("WORKFLOW_TASK_QUEUE is not set") - - worker = AgentexWorker(task_queue=task_queue_name) - - await worker.run( - activities=[run_claude_code_turn, *get_all_activities()], - workflow={{ workflow_class }}, - ) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/project/workflow.py.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/project/workflow.py.j2 deleted file mode 100644 index 8191ad80f..000000000 --- a/src/agentex/lib/cli/templates/temporal-claude-code/project/workflow.py.j2 +++ /dev/null @@ -1,148 +0,0 @@ -"""Temporal workflow for {{ agent_name }} โ€” Claude Code harness. - -Holds conversation state (session_id for multi-turn resume) durably across -crashes. Each user message triggers ``on_task_event_send``, which delegates the -turn to the ``run_claude_code_turn`` activity. The activity spawns the Claude -Code CLI, wraps its stdout in ``ClaudeCodeTurn``, and delivers the turn via -``UnifiedEmitter.auto_send_turn`` (the async Redis push path). - -Note on subprocess inside Temporal ------------------------------------- -Subprocess (and all other) I/O must run in a Temporal *activity*, never in -workflow code. Temporal runs workflow + signal-handler bodies on a -deterministic sandbox event loop that does not implement ``subprocess_exec`` -(spawning the CLI there raises ``NotImplementedError``). The activity also gets -Temporal's retry + timeout guarantees. -""" - -from __future__ import annotations - -import os -import json -import asyncio -from datetime import timedelta - -from temporalio import workflow - -from agentex.lib import adk -from agentex.lib.types.acp import SendEventParams, CreateTaskParams -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.types.text_content import TextContent -from agentex.lib.environment_variables import EnvironmentVariables -from agentex.lib.core.temporal.types.workflow import SignalName -from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config - -with workflow.unsafe.imports_passed_through(): - from project.activities import RunClaudeCodeTurnParams, run_claude_code_turn - -add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SGP_API_KEY", ""), - sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - ) -) - -environment_variables = EnvironmentVariables.refresh() - -if environment_variables.WORKFLOW_NAME is None: - raise ValueError("Environment variable WORKFLOW_NAME is not set") -if environment_variables.AGENT_NAME is None: - raise ValueError("Environment variable AGENT_NAME is not set") - -logger = make_logger(__name__) - - -@workflow.defn(name=environment_variables.WORKFLOW_NAME) -class {{ workflow_class }}(BaseWorkflow): - """Temporal workflow that runs Claude Code locally for each user message. - - Persists the Claude Code session_id across turns so the CLI can resume - the conversation (``-r ``). Temporal's durable state ensures - the session_id survives worker crashes. - """ - - def __init__(self): - super().__init__(display_name=environment_variables.AGENT_NAME) - self._complete_task = False - self._turn_number = 0 - # Claude Code session_id for multi-turn resume. - self._session_id: str | None = None - # Serialize turns: signal handlers can interleave at await points, so two - # quick messages could both read the same stale _session_id and run - # independent Claude Code sessions. The lock keeps turns sequential and - # preserves conversation continuity. - self._turn_lock = asyncio.Lock() - - @workflow.signal(name=SignalName.RECEIVE_EVENT) - async def on_task_event_send(self, params: SendEventParams) -> None: - """Handle a user message: spawn Claude Code and push events to the task stream.""" - async with self._turn_lock: - task_id = params.task.id - content = params.event.content - if not isinstance(content, TextContent): - logger.warning("Ignoring non-text event content (type=%s)", getattr(content, "type", "?")) - return - self._turn_number += 1 - prompt = content.content - logger.info("Turn %d for task %s", self._turn_number, task_id) - - await adk.messages.create(task_id=task_id, content=params.event.content) - - async with adk.tracing.span( - trace_id=task_id, - task_id=task_id, - name=f"Turn {self._turn_number}", - input={"message": prompt}, - ) as span: - # Delegate the subprocess turn to an activity: subprocess I/O is not - # permitted on the Temporal workflow event loop. The activity streams - # events to the task and returns the final text + session_id. - # workflow.now() gives a deterministic timestamp under replay. - result = await workflow.execute_activity( - run_claude_code_turn, - RunClaudeCodeTurnParams( - task_id=task_id, - prompt=prompt, - trace_id=task_id, - parent_span_id=span.id if span else None, - session_id=self._session_id, - created_at=workflow.now(), - ), - # Agentic Claude Code runs (multiple tool calls, large codegen) - # can take a while; tune this to your workload. - start_to_close_timeout=timedelta(minutes=30), - ) - - # Capture session_id to enable Claude Code resume on the next turn. - sid = result.get("session_id") - if sid: - self._session_id = sid - - if span: - span.output = {"final_text": result.get("final_text")} - - @workflow.run - async def on_task_create(self, params: CreateTaskParams) -> str: - logger.info("Task created: %s", params.task.id) - - await adk.messages.create( - task_id=params.task.id, - content=TextContent( - author="agent", - content=( - f"Task initialized with params:\n{json.dumps(params.params, indent=2)}\n" - "Send me a message and I'll run it through Claude Code locally." - ), - ), - ) - - await workflow.wait_condition(lambda: self._complete_task, timeout=None) - return "Task completed" - - @workflow.signal - async def complete_task_signal(self) -> None: - logger.info("Received complete_task signal") - self._complete_task = True diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/pyproject.toml.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/pyproject.toml.j2 deleted file mode 100644 index 2c6ec9c2f..000000000 --- a/src/agentex/lib/cli/templates/temporal-claude-code/pyproject.toml.j2 +++ /dev/null @@ -1,37 +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", - "python-dotenv>=1.0,<2", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "httpx", - "black", - "isort", - "flake8", - "debugpy>=1.8.15", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] - -[tool.black] -line-length = 88 -target-version = ['py312'] - -[tool.isort] -profile = "black" -line_length = 88 diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/requirements.txt.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/requirements.txt.j2 deleted file mode 100644 index a060d2331..000000000 --- a/src/agentex/lib/cli/templates/temporal-claude-code/requirements.txt.j2 +++ /dev/null @@ -1,11 +0,0 @@ -# Agentex SDK -agentex-sdk - -# Scale GenAI Platform Python SDK -scale-gp - -# Temporal workflow engine -temporalio>=1.18.2 - -# Loads .env files for local development -python-dotenv>=1.0,<2 diff --git a/src/agentex/lib/cli/templates/temporal-codex/.dockerignore.j2 b/src/agentex/lib/cli/templates/temporal-codex/.dockerignore.j2 deleted file mode 100644 index c2d7fca4d..000000000 --- a/src/agentex/lib/cli/templates/temporal-codex/.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-codex/.env.example.j2 b/src/agentex/lib/cli/templates/temporal-codex/.env.example.j2 deleted file mode 100644 index 5d621a83e..000000000 --- a/src/agentex/lib/cli/templates/temporal-codex/.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 used by the codex CLI (`codex exec` reads OPENAI_API_KEY directly) -OPENAI_API_KEY= - -# LLM base URL (optional - override to use a different provider) -# OPENAI_BASE_URL= - -# SGP Configuration (optional - for tracing) -# SGP_API_KEY= -# SGP_ACCOUNT_ID= -# SGP_CLIENT_BASE_URL= diff --git a/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 deleted file mode 100644 index 7e31387fa..000000000 --- a/src/agentex/lib/cli/templates/temporal-codex/Dockerfile-uv.j2 +++ /dev/null @@ -1,61 +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 the codex CLI: the activity shells out to `codex` on every turn, so -# the binary must be present in the runtime image. -RUN npm install -g @openai/codex - -# Install tctl (Temporal CLI) -RUN ARCH="$(uname -m)" && \ - case "$ARCH" in x86_64) TCTL_ARCH=amd64 ;; aarch64|arm64) TCTL_ARCH=arm64 ;; *) TCTL_ARCH=amd64 ;; esac && \ - curl -L "https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_${TCTL_ARCH}.tar.gz" -o /tmp/tctl.tar.gz && \ - tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ - chmod +x /usr/local/bin/tctl && \ - rm /tmp/tctl.tar.gz - -ENV UV_COMPILE_BYTECODE=1 -ENV UV_LINK_MODE=copy -ENV UV_HTTP_TIMEOUT=1000 - -WORKDIR /app/{{ project_path_from_build_root }} - -# Copy dependency files for layer caching -COPY {{ project_path_from_build_root }}/pyproject.toml ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-install-project --no-dev - -# Copy the project code -COPY {{ project_path_from_build_root }}/project ./project - -# Install the project -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-dev - -ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" - -# Run the ACP server using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] - -# When we deploy the worker, we will replace the CMD with the following -# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-codex/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-codex/Dockerfile.j2 deleted file mode 100644 index 0ae4e2079..000000000 --- a/src/agentex/lib/cli/templates/temporal-codex/Dockerfile.j2 +++ /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 the codex CLI: the activity shells out to `codex` on every turn, so -# the binary must be present in the runtime image. -RUN npm install -g @openai/codex - -# Install tctl (Temporal CLI) -RUN ARCH="$(uname -m)" && \ - case "$ARCH" in x86_64) TCTL_ARCH=amd64 ;; aarch64|arm64) TCTL_ARCH=arm64 ;; *) TCTL_ARCH=amd64 ;; esac && \ - curl -L "https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_${TCTL_ARCH}.tar.gz" -o /tmp/tctl.tar.gz && \ - tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ - chmod +x /usr/local/bin/tctl && \ - rm /tmp/tctl.tar.gz - -RUN uv pip install --system --upgrade pip setuptools wheel - -ENV UV_HTTP_TIMEOUT=1000 - -# Copy just the requirements file to optimize caching -COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt - -WORKDIR /app/{{ project_path_from_build_root }} - -# Install the required Python packages -RUN uv pip install --system -r requirements.txt - -# Copy the project code -COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project - -# Run the ACP server using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] - -# When we deploy the worker, we will replace the CMD with the following -# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-codex/README.md.j2 b/src/agentex/lib/cli/templates/temporal-codex/README.md.j2 deleted file mode 100644 index 794109ff3..000000000 --- a/src/agentex/lib/cli/templates/temporal-codex/README.md.j2 +++ /dev/null @@ -1,80 +0,0 @@ -# {{ agent_name }} โ€” AgentEx Temporal + Codex - -This template builds a **Temporal-durable** agent that drives the **Codex CLI** -through the unified harness surface on AgentEx: -- A Temporal workflow holds conversation state (the codex thread ID) durably - across worker crashes โ€” no external state store needed -- Each turn delegates to the `run_codex_turn` activity, which spawns the CLI - (subprocess I/O is not permitted on the workflow event loop) -- The activity wraps the CLI's stdout stream in a `CodexTurn` and delivers - canonical `StreamTaskMessage*` events via `UnifiedEmitter.auto_send_turn` -- Tracing integration to SGP / AgentEx - -## Prerequisites - -- The `codex` CLI installed and on your `PATH` (`npm install -g @openai/codex`) -- An `OPENAI_API_KEY` in your environment -- A running Temporal service (provided automatically by the local dev stack) - -## Running the Agent - -```bash -agentex agents run --manifest manifest.yaml -``` - -This starts both the ACP HTTP server and the Temporal worker. - -## Project Structure - -``` -{{ project_name }}/ -โ”œโ”€โ”€ project/ -โ”‚ โ”œโ”€โ”€ __init__.py -โ”‚ โ”œโ”€โ”€ acp.py # Thin ACP server; FastACP auto-wires to the workflow -โ”‚ โ”œโ”€โ”€ workflow.py # Temporal workflow (durable conversation state) -โ”‚ โ”œโ”€โ”€ activities.py # run_codex_turn activity (CLI subprocess) -โ”‚ โ””โ”€โ”€ run_worker.py # Temporal worker entrypoint -โ”œโ”€โ”€ Dockerfile -โ”œโ”€โ”€ manifest.yaml -โ”œโ”€โ”€ dev.ipynb -{% if use_uv %} -โ””โ”€โ”€ pyproject.toml -{% else %} -โ””โ”€โ”€ requirements.txt -{% endif %} -``` - -## Key Concepts - -### Subprocess must run in an activity -Temporal runs workflow + signal-handler bodies on a deterministic sandbox event -loop that does not implement `subprocess_exec`. The workflow therefore delegates -each turn to the `run_codex_turn` activity, which also gains Temporal's retry + -timeout guarantees. - -### Durable multi-turn memory -The codex thread ID is kept on the workflow instance; Temporal's durable replay -reconstructs it after a crash, so the next turn resumes the same codex session. - -### The unified harness surface -`CodexTurn` + `UnifiedEmitter` are the unified harness surface. The turn -normalizes CLI output into canonical AgentEx events; the emitter traces and -delivers them. - -## Development - -### 1. Choose a model -Set `CODEX_MODEL` (defaults to `o4-mini`) to control which model codex uses. - -### 2. Customize the subprocess -Edit `_spawn_codex` in `project/activities.py` to change the CLI flags or how -the prompt is delivered. - -### 3. Configure Credentials -Set your credentials via `manifest.yaml`, an exported environment variable, or a -`.env` file in the project directory. - -### 4. Run Locally -```bash -export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml -``` diff --git a/src/agentex/lib/cli/templates/temporal-codex/dev.ipynb.j2 b/src/agentex/lib/cli/templates/temporal-codex/dev.ipynb.j2 deleted file mode 100644 index d3a68303f..000000000 --- a/src/agentex/lib/cli/templates/temporal-codex/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-codex/environments.yaml.j2 b/src/agentex/lib/cli/templates/temporal-codex/environments.yaml.j2 deleted file mode 100644 index a3df5e228..000000000 --- a/src/agentex/lib/cli/templates/temporal-codex/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-codex/manifest.yaml.j2 b/src/agentex/lib/cli/templates/temporal-codex/manifest.yaml.j2 deleted file mode 100644 index 067567059..000000000 --- a/src/agentex/lib/cli/templates/temporal-codex/manifest.yaml.j2 +++ /dev/null @@ -1,142 +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 | tojson }} - - # Temporal workflow configuration - # This enables your agent to run as a Temporal workflow for long-running tasks - temporal: - enabled: true - workflows: - # Name of the workflow class - # Must match the @workflow.defn name in your workflow.py - - name: {{ workflow_name }} - - # Queue name for task distribution - # Used by Temporal to route tasks to your agent - # Convention: _task_queue - queue_name: {{ queue_name }} - - # Optional: Health check port for temporal worker - # Defaults to 80 if not specified - # health_check_port: 80 - - # Optional: Credentials mapping - # Maps Kubernetes secrets to environment variables - # Common credentials include: - credentials: - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - # The codex CLI spawned in project/activities.py reads OPENAI_API_KEY - # directly; without it every turn fails with an auth error. - - env_var_name: OPENAI_API_KEY - secret_name: openai-api-key - secret_key: api-key - - # Optional: Set Environment variables for running your agent locally as well - # as for deployment later on - env: {} - # LITELLM_API_KEY: "" - # OPENAI_BASE_URL: "" - # OPENAI_ORG_ID: "" - - -# Deployment Configuration -# ----------------------- -# Configuration for deploying your agent to Kubernetes clusters -deployment: - # Container image configuration - image: - repository: "" # Update with your container registry - tag: "latest" # Default tag, should be versioned in production - - imagePullSecrets: [] # Update with your image pull secret name - # - name: my-registry-secret - - # Global deployment settings that apply to all clusters - # These can be overridden in cluster-specific environments (environments.yaml) - global: - # Default replica count - replicaCount: 1 - - # Default resource requirements - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-codex/project/acp.py.j2 b/src/agentex/lib/cli/templates/temporal-codex/project/acp.py.j2 deleted file mode 100644 index 7ef5744f0..000000000 --- a/src/agentex/lib/cli/templates/temporal-codex/project/acp.py.j2 +++ /dev/null @@ -1,32 +0,0 @@ -"""ACP server for {{ agent_name }} โ€” a Temporal Codex harness agent. - -This file is intentionally thin. When ``acp_type="async"`` is combined with -``TemporalACPConfig(type="temporal", ...)``, FastACP auto-wires: - - HTTP task/create -> @workflow.run on the workflow class - HTTP task/event/send -> @workflow.signal(SignalName.RECEIVE_EVENT) - HTTP task/cancel -> workflow cancellation via the Temporal client - -so we don't define any handlers here. The actual agent code lives in -``project/workflow.py`` and is executed by the Temporal worker -(``project/run_worker.py``), not by this HTTP process. -""" - -from __future__ import annotations - -import os - -from dotenv import load_dotenv - -load_dotenv() - -from agentex.lib.types.fastacp import TemporalACPConfig -from agentex.lib.sdk.fastacp.fastacp import FastACP - -acp = FastACP.create( - acp_type="async", - config=TemporalACPConfig( - type="temporal", - temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), - ), -) diff --git a/src/agentex/lib/cli/templates/temporal-codex/project/activities.py.j2 b/src/agentex/lib/cli/templates/temporal-codex/project/activities.py.j2 deleted file mode 100644 index 0111794d9..000000000 --- a/src/agentex/lib/cli/templates/temporal-codex/project/activities.py.j2 +++ /dev/null @@ -1,151 +0,0 @@ -"""Temporal activity for {{ agent_name }} โ€” Codex harness. - -Subprocess spawning (and any other I/O) must run inside a Temporal *activity*, -not in workflow code. Temporal runs workflow + signal-handler bodies on a -deterministic sandbox event loop that does not implement ``subprocess_exec`` -(or threads / sockets), so spawning ``codex exec`` directly in the signal -handler raises ``NotImplementedError``. This activity runs codex, drives the -``CodexTurn`` through ``UnifiedEmitter.auto_send_turn`` (the async Redis push -path), and returns the turn result to the workflow. - -The ``_spawn_codex`` / ``_process_stdout`` seams are injectable: offline tests -can replace them with fakes that yield pre-recorded event lines so no real CLI -runs. -""" - -from __future__ import annotations - -import os -import codecs -import asyncio -from typing import Any -from datetime import datetime -from collections.abc import AsyncIterator - -from temporalio import activity - -from agentex.lib.adk import CodexTurn -from agentex.lib.core.harness import UnifiedEmitter -from agentex.lib.utils.logging import make_logger -from agentex.lib.utils.model_utils import BaseModel - -logger = make_logger(__name__) - -RUN_CODEX_TURN_ACTIVITY = "run_codex_turn" - - -class RunCodexTurnParams(BaseModel): - """Arguments for one codex turn run inside an activity.""" - - task_id: str - prompt: str - model: str - trace_id: str | None = None - parent_span_id: str | None = None - thread_id: str | None = None - created_at: datetime | None = None - - -class RunCodexTurnResult(BaseModel): - """Result returned from the activity to the workflow.""" - - final_text: str - session_id: str | None = None - model: str | None = None - - -async def _spawn_codex( - model: str, - thread_id: str | None = None, -) -> asyncio.subprocess.Process: - """Spawn ``codex exec --json`` locally and return the live process. - - Injection seam: tests replace this function with a fake that returns a - mock process whose stdout yields pre-recorded event lines. - - The caller writes the prompt to stdin after the process starts, then - closes stdin so codex knows input is complete. - """ - base_flags = [ - "--json", - "--skip-git-repo-check", - "--dangerously-bypass-approvals-and-sandbox", - "--model", - model, - ] - - if thread_id: - cmd = ["codex", "exec", *base_flags, "resume", thread_id, "-"] - else: - cmd = ["codex", "exec", *base_flags, "-"] - - return await asyncio.create_subprocess_exec( - *cmd, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - # Discard stderr: codex --json writes events to stdout; its stderr is - # progress/debug noise. Capturing it with PIPE but never reading it - # would deadlock once codex fills the OS pipe buffer (~64 KB). - stderr=asyncio.subprocess.DEVNULL, - env={**os.environ}, - ) - - -async def _process_stdout(process: asyncio.subprocess.Process) -> AsyncIterator[str]: - """Yield newline-delimited JSON lines from the process stdout. - - Uses an incremental UTF-8 decoder so a multibyte character split across two - 4 KB reads is decoded correctly instead of being corrupted at the boundary. - """ - assert process.stdout is not None - decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") - buffer = "" - while True: - chunk = await process.stdout.read(4096) - if not chunk: - break - buffer += decoder.decode(chunk) - while "\n" in buffer: - line, buffer = buffer.split("\n", 1) - line = line.strip() - if line: - yield line - buffer += decoder.decode(b"", final=True) - if buffer.strip(): - yield buffer.strip() - - -@activity.defn(name=RUN_CODEX_TURN_ACTIVITY) -async def run_codex_turn(params: RunCodexTurnParams) -> dict[str, Any]: - """Run one codex turn end-to-end and stream events to the task. - - Runs in an activity (real asyncio loop) so subprocess I/O is permitted. - """ - process = await _spawn_codex(params.model, thread_id=params.thread_id) - - assert process.stdin is not None - process.stdin.write(params.prompt.encode("utf-8")) - await process.stdin.drain() - process.stdin.close() - - turn = CodexTurn(events=_process_stdout(process), model=params.model) - emitter = UnifiedEmitter( - task_id=params.task_id, - trace_id=params.trace_id, - parent_span_id=params.parent_span_id, - ) - # Guarantee the subprocess is reaped even if auto_send_turn raises; - # otherwise codex stays blocked writing to a full stdout pipe buffer and the - # OS process leaks until the worker restarts. - try: - result = await emitter.auto_send_turn(turn, created_at=params.created_at) - finally: - if process.returncode is None: - process.kill() - await process.wait() - - return RunCodexTurnResult( - final_text=result.final_text, - session_id=turn.session_id, - model=turn.usage().model, - ).model_dump() diff --git a/src/agentex/lib/cli/templates/temporal-codex/project/run_worker.py.j2 b/src/agentex/lib/cli/templates/temporal-codex/project/run_worker.py.j2 deleted file mode 100644 index d86519977..000000000 --- a/src/agentex/lib/cli/templates/temporal-codex/project/run_worker.py.j2 +++ /dev/null @@ -1,41 +0,0 @@ -"""Temporal worker for {{ agent_name }} โ€” Codex harness. - -Run as a separate long-lived process alongside the ACP HTTP server. The -worker polls Temporal for workflow + activity tasks and executes them. - -The codex CLI subprocess runs in the ``run_codex_turn`` activity (registered -below alongside the built-in Agentex activities), because subprocess I/O is not -permitted on the Temporal workflow event loop. -""" - -import asyncio - -from project.workflow import {{ workflow_class }} -from project.activities import run_codex_turn -from agentex.lib.utils.debug import setup_debug_if_enabled -from agentex.lib.utils.logging import make_logger -from agentex.lib.environment_variables import EnvironmentVariables -from agentex.lib.core.temporal.activities import get_all_activities -from agentex.lib.core.temporal.workers.worker import AgentexWorker - -environment_variables = EnvironmentVariables.refresh() -logger = make_logger(__name__) - - -async def main(): - setup_debug_if_enabled() - - task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE - if task_queue_name is None: - raise ValueError("WORKFLOW_TASK_QUEUE is not set") - - worker = AgentexWorker(task_queue=task_queue_name) - - await worker.run( - activities=[run_codex_turn, *get_all_activities()], - workflow={{ workflow_class }}, - ) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/src/agentex/lib/cli/templates/temporal-codex/project/workflow.py.j2 b/src/agentex/lib/cli/templates/temporal-codex/project/workflow.py.j2 deleted file mode 100644 index 1004ebfb8..000000000 --- a/src/agentex/lib/cli/templates/temporal-codex/project/workflow.py.j2 +++ /dev/null @@ -1,157 +0,0 @@ -"""Temporal workflow for {{ agent_name }} โ€” Codex harness. - -Demonstrates the ``convert_codex_to_agentex_events`` tap + ``CodexTurn`` + -``UnifiedEmitter`` for a Temporal-durable ACP agent. - -KEY CONCEPTS DEMONSTRATED: -- Running ``codex exec --json`` in the ``run_codex_turn`` activity. Subprocess - I/O is not permitted on the Temporal workflow event loop (the deterministic - sandbox loop does not implement ``subprocess_exec``), so the signal handler - delegates the turn to an activity, which also gets Temporal's retry + timeout - guarantees. -- Wrapping the stdout line stream in a ``CodexTurn`` (inside the activity). -- Delivering events via ``UnifiedEmitter.auto_send_turn``, which pushes - ``StreamTaskMessage*`` events to Redis so the UI sees tokens in real time. -- Passing ``created_at=workflow.now()`` for deterministic timestamps under - Temporal replay (required for Temporal-safe delivery). -- Persisting the codex thread ID on the workflow instance itself โ€” Temporal's - workflow state is durable, so no external ``adk.state`` round-trip is needed. -""" - -from __future__ import annotations - -import os -import asyncio -from datetime import timedelta - -from temporalio import workflow - -from agentex.lib import adk -from agentex.lib.types.acp import SendEventParams, CreateTaskParams -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.types.text_content import TextContent -from agentex.lib.environment_variables import EnvironmentVariables -from agentex.lib.core.temporal.types.workflow import SignalName -from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config - -with workflow.unsafe.imports_passed_through(): - from project.activities import RunCodexTurnParams, run_codex_turn - -add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SGP_API_KEY", ""), - sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - ) -) - -environment_variables = EnvironmentVariables.refresh() - -if environment_variables.WORKFLOW_NAME is None: - raise ValueError("Environment variable WORKFLOW_NAME is not set") -if environment_variables.AGENT_NAME is None: - raise ValueError("Environment variable AGENT_NAME is not set") - -logger = make_logger(__name__) - -MODEL = os.environ.get("CODEX_MODEL", "o4-mini") - - -@workflow.defn(name=environment_variables.WORKFLOW_NAME) -class {{ workflow_class }}(BaseWorkflow): - """Long-running Temporal workflow that runs codex exec for each turn. - - Conversation state (codex thread ID + turn counter) is kept on the - workflow instance. Temporal's durable replay reconstructs this state if - the worker crashes, so no external ``adk.state`` round-trip is needed. - """ - - def __init__(self): - super().__init__(display_name=environment_variables.AGENT_NAME) - self._complete_task = False - self._turn_number = 0 - self._codex_thread_id: str | None = None - # Serialize turns: signal handlers can interleave at await points, so two - # quick messages could both read the same stale _codex_thread_id and fork - # the codex session. The lock keeps turns sequential and preserves - # conversation continuity. - self._turn_lock = asyncio.Lock() - - @workflow.signal(name=SignalName.RECEIVE_EVENT) - async def on_task_event_send(self, params: SendEventParams) -> None: - """Handle a new user message: spawn codex, stream events via UnifiedEmitter.""" - logger.info("Received task event: %s", params.task.id) - async with self._turn_lock: - content = params.event.content - if not isinstance(content, TextContent): - logger.warning("Ignoring non-text event content (type=%s)", getattr(content, "type", "?")) - return - - self._turn_number += 1 - - await adk.messages.create(task_id=params.task.id, content=params.event.content) - - user_message = content.content - - async with adk.tracing.span( - trace_id=params.task.id, - task_id=params.task.id, - name=f"Turn {self._turn_number}", - input={"message": user_message}, - ) as span: - # Delegate the subprocess turn to an activity: subprocess I/O is not - # permitted on the Temporal workflow event loop. The activity streams - # events to the task and returns the final text + codex thread id. - # workflow.now() gives a deterministic timestamp under replay. - result = await workflow.execute_activity( - run_codex_turn, - RunCodexTurnParams( - task_id=params.task.id, - prompt=user_message, - model=MODEL, - trace_id=params.task.id, - parent_span_id=span.id if span else None, - thread_id=self._codex_thread_id, - created_at=workflow.now(), - ), - start_to_close_timeout=timedelta(minutes=5), - ) - - # Persist the codex thread id so the next turn resumes the session. - session_id = result.get("session_id") - if session_id: - self._codex_thread_id = session_id - - if span: - span.output = { - "final_text": result.get("final_text"), - "model": result.get("model"), - } - - @workflow.run - async def on_task_create(self, params: CreateTaskParams) -> str: - """Workflow entry point โ€” keep the conversation alive for incoming signals.""" - logger.info("Task created: %s", params.task.id) - - await adk.messages.create( - task_id=params.task.id, - content=TextContent( - author="agent", - content=( - "Task initialized.\n" - "Send me a message and I'll run codex (local subprocess) " - "to answer, streaming events via the unified harness surface." - ), - ), - ) - - await workflow.wait_condition(lambda: self._complete_task, timeout=None) - return "Task completed" - - @workflow.signal - async def complete_task_signal(self) -> None: - """Graceful workflow shutdown signal.""" - logger.info("Received complete_task signal") - self._complete_task = True diff --git a/src/agentex/lib/cli/templates/temporal-codex/pyproject.toml.j2 b/src/agentex/lib/cli/templates/temporal-codex/pyproject.toml.j2 deleted file mode 100644 index 2c6ec9c2f..000000000 --- a/src/agentex/lib/cli/templates/temporal-codex/pyproject.toml.j2 +++ /dev/null @@ -1,37 +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", - "python-dotenv>=1.0,<2", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "httpx", - "black", - "isort", - "flake8", - "debugpy>=1.8.15", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] - -[tool.black] -line-length = 88 -target-version = ['py312'] - -[tool.isort] -profile = "black" -line_length = 88 diff --git a/src/agentex/lib/cli/templates/temporal-codex/requirements.txt.j2 b/src/agentex/lib/cli/templates/temporal-codex/requirements.txt.j2 deleted file mode 100644 index a060d2331..000000000 --- a/src/agentex/lib/cli/templates/temporal-codex/requirements.txt.j2 +++ /dev/null @@ -1,11 +0,0 @@ -# Agentex SDK -agentex-sdk - -# Scale GenAI Platform Python SDK -scale-gp - -# Temporal workflow engine -temporalio>=1.18.2 - -# Loads .env files for local development -python-dotenv>=1.0,<2 diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/.dockerignore.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/.dockerignore.j2 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 6746869df..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 ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-install-project --no-dev - -# Copy the project code -COPY {{ project_path_from_build_root }}/project ./project - -# Install the project -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-dev - -ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" - -# Run the ACP server using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] - -# When we deploy the worker, we will replace the CMD with the following -# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/Dockerfile.j2 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 b9216929f..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 | tojson }} - - # Temporal workflow configuration - # This enables your agent to run as a Temporal workflow for long-running tasks - temporal: - enabled: true - workflows: - # Name of the workflow class - # Must match the @workflow.defn name in your workflow.py - - name: {{ workflow_name }} - - # Queue name for task distribution - # Used by Temporal to route tasks to your agent - # Convention: _task_queue - queue_name: {{ queue_name }} - - # Optional: Health check port for temporal worker - # Defaults to 80 if not specified - # health_check_port: 80 - - # Optional: Credentials mapping - # Maps Kubernetes secrets to environment variables - # Common credentials include: - credentials: - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - # - env_var_name: LITELLM_API_KEY - # secret_name: litellm-api-key - # secret_key: api-key - - # Optional: Set Environment variables for running your agent locally as well - # as for deployment later on - env: {} - # LITELLM_API_KEY: "" - # OPENAI_BASE_URL: "" - # OPENAI_ORG_ID: "" - - -# Deployment Configuration -# ----------------------- -# Configuration for deploying your agent to Kubernetes clusters -deployment: - # Container image configuration - image: - repository: "" # Update with your container registry - tag: "latest" # Default tag, should be versioned in production - - imagePullSecrets: [] # Update with your image pull secret name - # - name: my-registry-secret - - # Global deployment settings that apply to all clusters - # These can be overridden in cluster-specific environments (environments.yaml) - global: - # Default replica count - replicaCount: 1 - - # Default resource requirements - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/project/acp.py.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/project/acp.py.j2 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 14bafabc1..000000000 --- a/src/agentex/lib/cli/templates/temporal-langgraph/project/workflow.py.j2 +++ /dev/null @@ -1,263 +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}") - content = params.event.content - if not isinstance(content, TextContent): - logger.warning("Ignoring non-text event content (type=%s)", getattr(content, "type", "?")) - return - self._turn_number += 1 - user_text = content.content - - # Echo the user's message so it shows up as a chat bubble. - await adk.messages.create(task_id=params.task.id, content=params.event.content) - self._messages.append({"role": "user", "content": user_text}) - - async with adk.tracing.span( - trace_id=params.task.id, - task_id=params.task.id, - name=f"Turn {self._turn_number}", - input={"message": user_text}, - ) as span: - final_text = await self._run_graph(params.task.id) - if span: - span.output = {"final_output": final_text} - - @workflow.signal - async def provide_approval(self, response: dict[str, Any]) -> None: - """Provide a human approval decision for a pending tool call. - - Args: - response: ``{"approved": bool, "reason": str, "approver": str}``. - """ - logger.info(f"Received approval response: {response}") - self._approval_response = response - - @workflow.signal - async def complete_task_signal(self) -> None: - """Gracefully end the task/workflow.""" - logger.info("Received complete_task signal") - self._complete_task = True - - # ------------------------------------------------------------------ # - # Agent turn โ€” run the LangGraph graph, pausing for approvals. - # ------------------------------------------------------------------ # - - async def _run_graph(self, task_id: str) -> str: - """Run one turn of the graph, handling any human-approval interrupts.""" - # A fresh in-memory checkpointer per turn: it only needs to persist the - # interrupt/resume state within this turn. Temporal provides durability; - # cross-turn memory lives in self._messages. - compiled = lg_graph(GRAPH_NAME).compile( - checkpointer=langgraph.checkpoint.memory.InMemorySaver() - ) - config = {"configurable": {"thread_id": f"{task_id}-{self._turn_number}"}} - - self._status = "processing" - result = await compiled.ainvoke({"messages": self._messages}, config=config) - - # The graph pauses (interrupt) whenever an approval-gated tool is called. - while result.get("__interrupt__"): - interrupt_value = result["__interrupt__"][0].value - decision = await self._await_human_approval(task_id, interrupt_value) - result = await compiled.ainvoke(Command(resume=decision), config=config) - - self._messages = result["messages"] - # Surface the messages this turn produced (tool calls, results, final - # text) to the AgentEx UI. The SDK helper does the LangGraphโ†’AgentEx - # message conversion and returns the final assistant text. - final_text = await emit_langgraph_messages(self._messages[self._emitted:], task_id) - self._emitted = len(self._messages) - self._status = "completed" - return final_text - - async def _await_human_approval(self, task_id: str, pending: dict[str, Any]) -> dict[str, Any]: - """Pause until a ``provide_approval`` signal arrives, then return the decision.""" - self._pending_approval = pending - self._approval_response = None - self._status = "waiting_for_approval" - - await adk.messages.create( - task_id=task_id, - content=TextContent( - author="agent", - content=( - f"โธ๏ธ Waiting for human approval to run **{pending.get('name')}** " - f"with `{json.dumps(pending.get('args', {}))}`.\n\n" - "Send a `provide_approval` signal, e.g. " - '`{"approved": true, "approver": "you"}`.' - ), - ), - ) - - await workflow.wait_condition(lambda: self._approval_response is not None) - - decision = self._approval_response or {"approved": False} - self._pending_approval = None - self._approval_response = None - self._status = "processing" - return decision - - # ------------------------------------------------------------------ # - # Queries โ€” inspect the running agent live from the Temporal UI/client. - # ------------------------------------------------------------------ # - - @workflow.query - def get_status(self) -> str: - """Current status: idle | processing | waiting_for_approval | completed.""" - return self._status - - @workflow.query - def get_pending_approval(self) -> dict[str, Any] | None: - """The tool call currently awaiting human approval, if any.""" - return self._pending_approval - - @workflow.query - def get_graph_state(self) -> dict[str, Any]: - """A snapshot of the agent loop's progress.""" - return { - "turn_number": self._turn_number, - "message_count": len(self._messages), - "status": self._status, - "pending_approval": self._pending_approval, - "completed": self._complete_task, - } - - @workflow.query - def get_graph_mermaid(self) -> str: - """Mermaid diagram of the agent graph (renders in GitHub/Notion).""" - try: - return self._visualization_graph().get_graph().draw_mermaid() - except Exception as exc: # pragma: no cover - visualization is best-effort - return f"Could not render graph: {exc}" - - @workflow.query - def get_graph_ascii(self) -> str: - """ASCII-art diagram of the agent graph (requires the `grandalf` package).""" - try: - return self._visualization_graph().get_graph().draw_ascii() - except ImportError: - return "ASCII rendering requires the 'grandalf' package. Try get_graph_mermaid instead." - except Exception as exc: # pragma: no cover - visualization is best-effort - return f"Could not render graph: {exc}" - - def _visualization_graph(self): - """Lazily build + cache the compiled graph used purely for rendering.""" - if self._viz_graph is None: - self._viz_graph = build_graph().compile() - return self._viz_graph - - # ------------------------------------------------------------------ # - # Entry point - # ------------------------------------------------------------------ # - - @workflow.run - async def on_task_create(self, params: CreateTaskParams) -> str: - """Keep the conversation alive, handling incoming message/approval signals.""" - logger.info(f"Task created: {params.task.id}") - - await adk.messages.create( - task_id=params.task.id, - content=TextContent( - author="agent", - content=( - f"Task initialized with params:\n{json.dumps(params.params, indent=2)}\n\n" - "Send me a message and I'll respond using a LangGraph agent whose nodes " - "run as durable Temporal activities." - ), - ), - ) - - await workflow.wait_condition(lambda: self._complete_task, timeout=None) - return "Task completed" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-langgraph/pyproject.toml.j2 b/src/agentex/lib/cli/templates/temporal-langgraph/pyproject.toml.j2 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 0d9801016..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 ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-install-project --no-dev - -# Copy the project code -COPY {{ project_path_from_build_root }}/project ./project - -# Install the project -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-dev - -ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" - -# Run the ACP server using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] - -# When we deploy the worker, we will replace the CMD with the following -# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/Dockerfile.j2 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 b9216929f..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 | tojson }} - - # Temporal workflow configuration - # This enables your agent to run as a Temporal workflow for long-running tasks - temporal: - enabled: true - workflows: - # Name of the workflow class - # Must match the @workflow.defn name in your workflow.py - - name: {{ workflow_name }} - - # Queue name for task distribution - # Used by Temporal to route tasks to your agent - # Convention: _task_queue - queue_name: {{ queue_name }} - - # Optional: Health check port for temporal worker - # Defaults to 80 if not specified - # health_check_port: 80 - - # Optional: Credentials mapping - # Maps Kubernetes secrets to environment variables - # Common credentials include: - credentials: - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - # - env_var_name: LITELLM_API_KEY - # secret_name: litellm-api-key - # secret_key: api-key - - # Optional: Set Environment variables for running your agent locally as well - # as for deployment later on - env: {} - # LITELLM_API_KEY: "" - # OPENAI_BASE_URL: "" - # OPENAI_ORG_ID: "" - - -# Deployment Configuration -# ----------------------- -# Configuration for deploying your agent to Kubernetes clusters -deployment: - # Container image configuration - image: - repository: "" # Update with your container registry - tag: "latest" # Default tag, should be versioned in production - - imagePullSecrets: [] # Update with your image pull secret name - # - name: my-registry-secret - - # Global deployment settings that apply to all clusters - # These can be overridden in cluster-specific environments (environments.yaml) - global: - # Default replica count - replicaCount: 1 - - # Default resource requirements - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/project/acp.py.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/project/acp.py.j2 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 af8b7a299..000000000 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/project/workflow.py.j2 +++ /dev/null @@ -1,181 +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}") - - content = params.event.content - if not isinstance(content, TextContent): - logger.warning("Ignoring non-text event content (type=%s)", getattr(content, "type", "?")) - return - - # Increment turn number for tracing - self._state.turn_number += 1 - - self._task_id = params.task.id - self._trace_id = params.task.id - self._parent_span_id = params.task.id - - # Add the user message to conversation history - self._state.input_list.append({"role": "user", "content": content.content}) - - # Echo back the client's message to show it in the UI - await adk.messages.create(task_id=params.task.id, content=params.event.content) - - temporal_streaming_hooks = TemporalStreamingHooks(task_id=params.task.id) - - # Create a span to track this turn of the conversation - turn_input = TurnInput( - input_list=self._state.input_list, - ) - async with adk.tracing.span( - trace_id=params.task.id, - name=f"Turn {self._state.turn_number}", - input=turn_input.model_dump(), - ) as span: - self._parent_span_id = span.id if span else None - - # Create the OpenAI agent with tools - # Add your activities as tools using activity_as_tool() - agent = Agent( - name="{{ agent_name }}", - instructions="You are a helpful assistant. Use your tools to help the user.", - model="gpt-4o-mini", - tools=[ - openai_agents.workflow.activity_as_tool( - get_weather, - start_to_close_timeout=timedelta(minutes=5), - ), - # Add more tools here as you create new activities: - # openai_agents.workflow.activity_as_tool( - # your_new_activity, - # start_to_close_timeout=timedelta(minutes=5), - # ), - ], - ) - - # Run the agent with hooks to enable streaming responses - result = await Runner.run(agent, self._state.input_list, hooks=temporal_streaming_hooks) - - # Update the state with the assistant's response for the next turn - self._state.input_list = result.to_input_list() # type: ignore[assignment] - - # Set span output for tracing - include full state - if span: - turn_output = TurnOutput(final_output=result.final_output) - span.output = turn_output.model_dump() - - @workflow.run - async def on_task_create(self, params: CreateTaskParams) -> str: - logger.info(f"Received task create params: {params}") - - # Acknowledge that the task has been created - await adk.messages.create( - task_id=params.task.id, - content=TextContent( - author="agent", - content=f"Hello! I'm {{ agent_name }}, your AI assistant. How can I help you today?\n\nParams received:\n{json.dumps(params.params, indent=2)}", - ), - ) - - await workflow.wait_condition( - lambda: self._complete_task, - timeout=None, - ) - return "Task completed" diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/pyproject.toml.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/pyproject.toml.j2 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 0d9801016..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 ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-install-project --no-dev - -# Copy the project code -COPY {{ project_path_from_build_root }}/project ./project - -# Install the project -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-dev - -ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" - -# Run the ACP server using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] - -# When we deploy the worker, we will replace the CMD with the following -# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/Dockerfile.j2 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 b9216929f..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 | tojson }} - - # Temporal workflow configuration - # This enables your agent to run as a Temporal workflow for long-running tasks - temporal: - enabled: true - workflows: - # Name of the workflow class - # Must match the @workflow.defn name in your workflow.py - - name: {{ workflow_name }} - - # Queue name for task distribution - # Used by Temporal to route tasks to your agent - # Convention: _task_queue - queue_name: {{ queue_name }} - - # Optional: Health check port for temporal worker - # Defaults to 80 if not specified - # health_check_port: 80 - - # Optional: Credentials mapping - # Maps Kubernetes secrets to environment variables - # Common credentials include: - credentials: - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - # - env_var_name: LITELLM_API_KEY - # secret_name: litellm-api-key - # secret_key: api-key - - # Optional: Set Environment variables for running your agent locally as well - # as for deployment later on - env: {} - # LITELLM_API_KEY: "" - # OPENAI_BASE_URL: "" - # OPENAI_ORG_ID: "" - - -# Deployment Configuration -# ----------------------- -# Configuration for deploying your agent to Kubernetes clusters -deployment: - # Container image configuration - image: - repository: "" # Update with your container registry - tag: "latest" # Default tag, should be versioned in production - - imagePullSecrets: [] # Update with your image pull secret name - # - name: my-registry-secret - - # Global deployment settings that apply to all clusters - # These can be overridden in cluster-specific environments (environments.yaml) - global: - # Default replica count - replicaCount: 1 - - # Default resource requirements - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/acp.py.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/acp.py.j2 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 da97856ea..000000000 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/agent.py.j2 +++ /dev/null @@ -1,115 +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 through the unified harness surface -(``UnifiedEmitter.auto_send_turn`` + ``PydanticAITurn``). The ``task_id`` and -tracing parent span ID are threaded into the handler via ``deps``. -""" - -from __future__ import annotations - -from datetime import datetime -from collections.abc import AsyncIterable - -from pydantic import BaseModel -from pydantic_ai import Agent, RunContext -from project.tools import get_weather -from pydantic_ai.messages import AgentStreamEvent -from pydantic_ai.durable_exec.temporal import TemporalAgent - -from agentex.lib.core.harness import UnifiedEmitter -from agentex.lib.adk import PydanticAITurn - -# Swap this for any Pydantic AI-supported model identifier -# (e.g. "anthropic:claude-3-5-sonnet-latest", "openai:gpt-4o"). -MODEL_NAME = "openai:gpt-4o-mini" - -SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. - -Current date and time: {timestamp} - -Guidelines: -- Be concise and helpful -- Use tools when they would help answer the user's question -- If you're unsure, ask clarifying questions -- Always provide accurate information -""" - - -class TaskDeps(BaseModel): - """Per-run dependencies passed into the agent via ``deps=``. - - Pydantic AI's ``RunContext.deps`` is the canonical place to thread - request-scoped data (like the Agentex task_id) into tools and event - handlers โ€” including code that runs inside Temporal activities. - """ - - task_id: str - # When set, the event handler nests per-tool-call spans under this - # span. Typically the ID of the per-turn span opened by the workflow. - parent_span_id: str | None = None - - -def _build_base_agent() -> Agent[TaskDeps, str]: - """Build the underlying Pydantic AI agent with tools registered. - - Tools must be registered BEFORE the agent is wrapped in TemporalAgent; - changes to tool registration after wrapping are not reflected. - """ - agent: Agent[TaskDeps, str] = Agent( - MODEL_NAME, - deps_type=TaskDeps, - system_prompt=SYSTEM_PROMPT.format( - timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - ), - ) - - # Register additional tools by adding more `agent.tool_plain(...)` calls. - agent.tool_plain(get_weather) - return agent - - -async def event_handler( - run_context: RunContext[TaskDeps], - events: AsyncIterable[AgentStreamEvent], -) -> None: - """Stream Pydantic AI events to Agentex via Redis from inside the model activity. - - Pydantic AI calls this with the live event stream as soon as the model - activity begins emitting parts. Because the handler runs inside the - activity (not the workflow), it can freely make non-deterministic Redis - writes โ€” including the tracing HTTP calls that record per-tool-call - spans under the workflow's per-turn span (when ``parent_span_id`` is set). - - The UnifiedEmitter is constructed from ``deps`` (task_id + parent_span_id), - so tool spans nest under the workflow's per-turn span and messages auto-send - to the task stream. - """ - emitter = UnifiedEmitter( - task_id=run_context.deps.task_id, - trace_id=run_context.deps.task_id, - parent_span_id=run_context.deps.parent_span_id, - ) - turn = PydanticAITurn(events, model=MODEL_NAME) - await emitter.auto_send_turn(turn) - - -# Construct the durable agent at module load time so that the -# PydanticAIPlugin can auto-discover its activities via the workflow's -# ``__pydantic_ai_agents__`` attribute. -base_agent = _build_base_agent() -temporal_agent: TemporalAgent[TaskDeps, str] = TemporalAgent( - base_agent, - name="{{ project_name }}_agent", - event_stream_handler=event_handler, -) diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/run_worker.py.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/run_worker.py.j2 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 6dcca3002..000000000 --- a/src/agentex/lib/cli/templates/temporal-pydantic-ai/project/workflow.py.j2 +++ /dev/null @@ -1,153 +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}") - - content = params.event.content - if not isinstance(content, TextContent): - logger.warning("Ignoring non-text event content (type=%s)", getattr(content, "type", "?")) - return - user_message = content.content - - self._turn_number += 1 - - # Echo the user's message so it shows up in the UI as a chat bubble. - await adk.messages.create(task_id=params.task.id, content=params.event.content) - - async with adk.tracing.span( - trace_id=params.task.id, - task_id=params.task.id, - name=f"Turn {self._turn_number}", - input={"message": user_message}, - ) as span: - # temporal_agent.run() is the magic line. Internally it schedules - # a model activity (LLM HTTP call) and, for each tool the model - # invokes, a separate tool activity. Each is independently - # durable and retried. While the model activity runs, the - # event_stream_handler on temporal_agent pushes deltas to Redis - # so the UI sees tokens stream live. - # - # Passing ``message_history`` makes the run remember prior turns; - # without it the agent would respond to each user message as if - # it had never seen the conversation before. - result = await temporal_agent.run( - user_message, - message_history=self._message_history, - deps=TaskDeps( - task_id=params.task.id, - parent_span_id=span.id if span else None, - ), - ) - # Persist the new full history (user + assistant + any tool - # rounds) so the next turn picks up from here. - self._message_history = list(result.all_messages()) - if span: - span.output = {"final_output": result.output} - - @workflow.run - async def on_task_create(self, params: CreateTaskParams) -> str: - """Workflow entry point โ€” keep the conversation alive for incoming signals.""" - logger.info(f"Task created: {params.task.id}") - - await adk.messages.create( - task_id=params.task.id, - content=TextContent( - author="agent", - content=( - f"Task initialized with params:\n{json.dumps(params.params, indent=2)}\n" - f"Send me a message and I'll respond using a Pydantic AI agent backed by Temporal." - ), - ), - ) - - await workflow.wait_condition(lambda: self._complete_task, timeout=None) - return "Task completed" - - @workflow.signal - async def complete_task_signal(self) -> None: - """Graceful workflow shutdown signal.""" - logger.info("Received complete_task signal") - self._complete_task = True diff --git a/src/agentex/lib/cli/templates/temporal-pydantic-ai/pyproject.toml.j2 b/src/agentex/lib/cli/templates/temporal-pydantic-ai/pyproject.toml.j2 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 0d9801016..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 ./ - -# Install dependencies (without project itself, for layer caching) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-install-project --no-dev - -# Copy the project code -COPY {{ project_path_from_build_root }}/project ./project - -# Install the project -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --no-dev - -ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" - -# Run the ACP server using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] - -# When we deploy the worker, we will replace the CMD with the following -# CMD ["python", "-m", "run_worker"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal/Dockerfile.j2 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 b9216929f..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 | tojson }} - - # Temporal workflow configuration - # This enables your agent to run as a Temporal workflow for long-running tasks - temporal: - enabled: true - workflows: - # Name of the workflow class - # Must match the @workflow.defn name in your workflow.py - - name: {{ workflow_name }} - - # Queue name for task distribution - # Used by Temporal to route tasks to your agent - # Convention: _task_queue - queue_name: {{ queue_name }} - - # Optional: Health check port for temporal worker - # Defaults to 80 if not specified - # health_check_port: 80 - - # Optional: Credentials mapping - # Maps Kubernetes secrets to environment variables - # Common credentials include: - credentials: - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - # - env_var_name: LITELLM_API_KEY - # secret_name: litellm-api-key - # secret_key: api-key - - # Optional: Set Environment variables for running your agent locally as well - # as for deployment later on - env: {} - # LITELLM_API_KEY: "" - # OPENAI_BASE_URL: "" - # OPENAI_ORG_ID: "" - - -# Deployment Configuration -# ----------------------- -# Configuration for deploying your agent to Kubernetes clusters -deployment: - # Container image configuration - image: - repository: "" # Update with your container registry - tag: "latest" # Default tag, should be versioned in production - - imagePullSecrets: [] # Update with your image pull secret name - # - name: my-registry-secret - - # Global deployment settings that apply to all clusters - # These can be overridden in cluster-specific environments (environments.yaml) - global: - # Default replica count - replicaCount: 1 - - # Default resource requirements - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/temporal/project/acp.py.j2 b/src/agentex/lib/cli/templates/temporal/project/acp.py.j2 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 b2a747456..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.config.agent_manifest import AgentManifest -from agentex.config.environment_config import AgentAuthConfig - - -# DEPRECATED: Old function for backward compatibility -# Will be removed in future version -def _encode_principal_context(manifest: AgentManifest) -> str | None: # noqa: ARG001 - """ - DEPRECATED: This function is deprecated as AgentManifest no longer contains auth. - Use _encode_principal_context_from_env_config instead. - - This function is kept temporarily for backward compatibility during migration. - """ - # AgentManifest no longer has auth field - this will always return None - return None - - -def _encode_principal_context_from_env_config(auth_config: "AgentAuthConfig | None") -> str | None: - """ - Encode principal context from environment configuration. - - Args: - auth_config: AgentAuthConfig containing principal configuration - - Returns: - Base64-encoded JSON string of the principal, or None if no principal - """ - if auth_config is None: - return None - - principal = auth_config.principal - if not principal: - return None - - json_str = json.dumps(principal, separators=(',', ':')) - encoded_bytes = base64.b64encode(json_str.encode('utf-8')) - return encoded_bytes.decode('utf-8') - - -def _encode_principal_dict(principal: Dict[str, Any]) -> str | None: - """ - Encode principal dictionary directly. - - Args: - principal: Dictionary containing principal configuration - - Returns: - Base64-encoded JSON string of the principal, or None if principal is empty - """ - if not principal: - return None - - json_str = json.dumps(principal, separators=(',', ':')) - encoded_bytes = base64.b64encode(json_str.encode('utf-8')) - return encoded_bytes.decode('utf-8') diff --git a/src/agentex/lib/cli/utils/cli_utils.py b/src/agentex/lib/cli/utils/cli_utils.py 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 720b784b8..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.config.credentials import CredentialMapping - -console = Console() - - -def check_secret_exists(secret_name: str, namespace: str) -> bool: - """Check if a Kubernetes secret exists in the given namespace.""" - try: - result = subprocess.run( - ["kubectl", "get", "secret", secret_name, "-n", namespace], - capture_output=True, - text=True, - check=False, - ) - return result.returncode == 0 - except Exception: - return False - - -def create_env_var_secret(credential: CredentialMapping, namespace: str) -> bool: - """Create a generic secret for environment variable credentials.""" - console.print( - f"[yellow]Secret '{credential.secret_name}' not found in namespace '{namespace}'[/yellow]" - ) - - if not Confirm.ask( - f"Would you like to create the secret '{credential.secret_name}'?" - ): - return False - - # Prompt for the secret value - secret_value = Prompt.ask( - f"Enter the value for '{credential.secret_key}'", password=True - ) - - try: - # Create the secret using kubectl - subprocess.run( - [ - "kubectl", - "create", - "secret", - "generic", - credential.secret_name, - f"--from-literal={credential.secret_key}={secret_value}", - "-n", - namespace, - ], - capture_output=True, - text=True, - check=True, - ) - - console.print( - f"[green]โœ“ Created secret '{credential.secret_name}' in namespace '{namespace}'[/green]" - ) - return True - - except subprocess.CalledProcessError as e: - console.print(f"[red]โœ— Failed to create secret: {e.stderr}[/red]") - return False - - -# def create_image_pull_secret(credential: ImagePullCredential, namespace: str) -> bool: -# """Create an image pull secret with interactive prompts.""" -# console.print(f"[yellow]Image pull secret '{credential.secret_name}' not found in namespace '{namespace}'[/yellow]") - -# if not Confirm.ask(f"Would you like to create the image pull secret '{credential.secret_name}'?"): -# return False - -# # Prompt for registry details -# registry_server = Prompt.ask("Docker registry server (e.g., docker.io, gcr.io)") -# username = Prompt.ask("Username") -# password = Prompt.ask("Password", password=True) -# email = Prompt.ask("Email (optional)", default="") - -# try: -# # Create the image pull secret using kubectl -# cmd = [ -# "kubectl", "create", "secret", "docker-registry", -# credential.secret_name, -# f"--docker-server={registry_server}", -# f"--docker-username={username}", -# f"--docker-password={password}", -# "-n", namespace -# ] - -# if email: -# cmd.append(f"--docker-email={email}") - -# result = subprocess.run(cmd, capture_output=True, text=True, check=True) - -# console.print(f"[green]โœ“ Created image pull secret '{credential.secret_name}' in namespace '{namespace}'[/green]") -# return True - -# except subprocess.CalledProcessError as e: -# console.print(f"[red]โœ— Failed to create image pull secret: {e.stderr}[/red]") -# return False diff --git a/src/agentex/lib/cli/utils/exceptions.py b/src/agentex/lib/cli/utils/exceptions.py 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 d6c881f6d..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.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 02c44d81e..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 | None = None, - **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/compat/__init__.py b/src/agentex/lib/core/compat/__init__.py deleted file mode 100644 index 8b1378917..000000000 --- a/src/agentex/lib/core/compat/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/agentex/lib/core/compat/version_guard.py b/src/agentex/lib/core/compat/version_guard.py deleted file mode 100644 index 56933de0b..000000000 --- a/src/agentex/lib/core/compat/version_guard.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Runtime SDK โ†” backend contract-version guard. - -Complements the *build-time* cross-version compatibility tests (``tests/compat``): - -- **Build-time** (CI): is this *client* compatible with the window of supported server - contracts (``min-supported``..``current``)? -- **Runtime** (this module): is the *server* the SDK is pointed at within that window? - -It runs once at ACP/worker startup, reads the backend's contract version (the version -the server already reports via ``/openapi.json`` ``info.version``), and **fails fast with -an actionable error** if the backend is older than this SDK supports โ€” instead of the -mismatch surfacing later as opaque 500s / missing-field errors deep in a request. - -``MIN_BACKEND_CONTRACT`` is the same source of truth as the ``min-supported`` server -contract in ``tests/compat/server_specs/manifest.json``: the oldest agentex backend this -SDK version supports. Bump both together when a breaking change raises the floor. -""" - -from __future__ import annotations - -import os -import re - -import httpx - -from agentex.lib.utils.logging import make_logger - -logger = make_logger(__name__) - -# Oldest agentex backend contract this SDK is compatible with. -# Keep in sync with the `min-supported` spec in tests/compat (#407); the version axis -# itself comes from scale-agentex release tags (#321). Bump on a breaking SDK change. -MIN_BACKEND_CONTRACT = "0.1.0" - -SKIP_ENV = "AGENTEX_SKIP_VERSION_CHECK" - -# Full-string SemVer. Accepts: `1.2.3`, leading `v`, surrounding whitespace, `-prerelease` -# (captured), `+build` (ignored). Anchored at both ends so a malformed tail (`0.1.0rc1`, -# `0.1.0.1`) is rejected โ†’ None โ†’ "unknown, proceed", not silently coerced to stable `0.1.0`. -_VERSION_RE = re.compile( - r"^\s*v?(\d+)\.(\d+)\.(\d+)" # major.minor.patch - r"(?:-([0-9A-Za-z.-]+))?" # optional -prerelease (captured) - r"(?:\+[0-9A-Za-z.-]+)?" # optional +build metadata (ignored) - r"\s*$" -) - - -class IncompatibleBackendError(RuntimeError): - """Raised when the agentex backend is older than this SDK's minimum supported contract.""" - - -def _parse(version: str | None) -> tuple[int, int, int, str | None] | None: - """Parse ``major.minor.patch[-prerelease]`` โ†’ ``(major, minor, patch, prerelease)``. - - ``prerelease`` is the raw dot-separated identifier string (e.g. ``"rc.1"``), or None for - a stable release. Build metadata (after ``+``) is ignored. Returns None if unparseable. - """ - m = _VERSION_RE.match(version or "") - if not m: - return None - return (int(m.group(1)), int(m.group(2)), int(m.group(3)), m.group(4) or None) - - -# Comparable SemVer precedence key. The 4th element keeps a uniform shape across stable and -# prerelease so the whole tuple is orderable: (rank, identifiers), where stable rank 1 > prerelease -# rank 0 (and the identifier list is only ever compared when both sides are prereleases, rank 0). -_PreKey = tuple[int, int, int, tuple[int, list[tuple[int, int, str]]]] - - -def _precedence_key(parsed: tuple[int, int, int, str | None]) -> _PreKey: - """SemVer ยง11 precedence key (directly comparable with ``<``). - - A stable release outranks any prerelease of the same triplet (``0.1.0-rc.1 < 0.1.0``); - among prereleases, numeric identifiers rank below alphanumeric and compare field-by-field, - with a longer identifier list outranking a shorter prefix-equal one. - """ - major, minor, patch, prerelease = parsed - if prerelease is None: - return (major, minor, patch, (1, [])) # stable sorts above every prerelease - identifiers: list[tuple[int, int, str]] = [] - for ident in prerelease.split("."): - if ident.isdigit(): - identifiers.append((0, int(ident), "")) # numeric: lowest class, numeric order - else: - identifiers.append((1, 0, ident)) # alphanumeric: higher class, lexical order - return (major, minor, patch, (0, identifiers)) - - -def _truthy(name: str) -> bool: - return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on") - - -async def fetch_backend_version(base_url: str, *, timeout: float = 5.0) -> str | None: - """Return the backend's reported contract version (``/openapi.json`` ``info.version``), or None.""" - url = base_url.rstrip("/") + "/openapi.json" - try: - async with httpx.AsyncClient(timeout=timeout) as client: - resp = await client.get(url) - resp.raise_for_status() - return (resp.json().get("info") or {}).get("version") - except Exception as exc: # noqa: BLE001 - any failure โ†’ unknown, handled by caller - logger.warning("backend version guard: could not fetch %s (%s)", url, exc) - return None - - -async def assert_backend_compatible( - base_url: str | None, - *, - min_version: str = MIN_BACKEND_CONTRACT, - sdk_version: str | None = None, -) -> None: - """Fail fast at startup if the backend is older than ``min_version``. - - No-op (warns, does not raise) when: - - ``AGENTEX_SKIP_VERSION_CHECK`` is set (explicit bypass), - - ``base_url`` is unset, - - the backend version can't be determined (unreachable / unparseable) โ€” a transient - blip or a contract-less server shouldn't crash startup. - - Raises ``IncompatibleBackendError`` only when the backend version is *known* and older - than ``min_version``. - """ - if _truthy(SKIP_ENV): - logger.warning("%s set โ€” skipping backend version guard", SKIP_ENV) - return - if not base_url: - return - - if sdk_version is None: - from agentex._version import __version__ as sdk_version # local import to avoid cycles - - backend_version = await fetch_backend_version(base_url) - if backend_version is None: - logger.warning( - "backend version guard: could not determine backend version at %s; proceeding " - "(set %s=1 to silence).", - base_url, - SKIP_ENV, - ) - return - - backend, minimum = _parse(backend_version), _parse(min_version) - if backend is None or minimum is None: - logger.warning( - "backend version guard: unparseable version(s) backend=%r min=%r; proceeding.", - backend_version, - min_version, - ) - return - - if _precedence_key(backend) < _precedence_key(minimum): - raise IncompatibleBackendError( - f"agentex-sdk {sdk_version} requires agentex backend >= {min_version}, " - f"but {base_url} reports {backend_version}. " - f"Upgrade the backend, or pin agentex-sdk to a version compatible with backend " - f"{backend_version}. (Set {SKIP_ENV}=1 to bypass at your own risk.)" - ) - - logger.info( - "backend version guard OK: sdk=%s backend=%s (min=%s)", - sdk_version, - backend_version, - min_version, - ) diff --git a/src/agentex/lib/core/harness/__init__.py b/src/agentex/lib/core/harness/__init__.py deleted file mode 100644 index 067751d63..000000000 --- a/src/agentex/lib/core/harness/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Shared, harness-independent machinery for the unified harness surface. - -The Agentex StreamTaskMessage* stream is the single source of truth; this -package derives spans from it and delivers it (yield or auto-send), so every -harness tap gets streaming + tracing + turn usage uniformly. -""" - -from agentex.lib.core.harness.types import ( - OpenSpan, - CloseSpan, - TurnUsage, - SpanSignal, - TurnResult, - HarnessTurn, - StreamTaskMessage, -) -from agentex.lib.core.harness.tracer import SpanTracer -from agentex.lib.core.harness.emitter import UnifiedEmitter - -__all__ = [ - "UnifiedEmitter", - "SpanTracer", - "OpenSpan", - "CloseSpan", - "SpanSignal", - "StreamTaskMessage", - "TurnUsage", - "TurnResult", - "HarnessTurn", -] diff --git a/src/agentex/lib/core/harness/auto_send.py b/src/agentex/lib/core/harness/auto_send.py deleted file mode 100644 index b645a4aae..000000000 --- a/src/agentex/lib/core/harness/auto_send.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Auto-send delivery: canonical stream -> adk.streaming side effects + tracing.""" - -from __future__ import annotations - -from typing import Any, AsyncIterator -from datetime import datetime - -from agentex.types.text_delta import TextDelta -from agentex.types.text_content import TextContent -from agentex.lib.core.harness.types import TurnUsage, TurnResult, StreamTaskMessage -from agentex.lib.core.harness.tracer import SpanTracer -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.lib.core.harness.span_derivation import SpanDeriver - -try: - from agentex.lib.utils.logging import make_logger - - logger = make_logger(__name__) -except Exception: # ddtrace may be absent in some envs; fall back to stdlib - import logging - - logger = logging.getLogger(__name__) - - -async def auto_send( - events: AsyncIterator[StreamTaskMessage], - task_id: str, - tracer: SpanTracer | None = None, - streaming: Any = None, - usage: TurnUsage | None = None, - created_at: datetime | None = None, -) -> TurnResult: - """Push the canonical stream to the task stream via adk.streaming. - - Opens a streaming context per message (keyed by index), streams deltas via - ctx.stream_update, and closes via ctx.close() on Done. Posts tool - request/response full messages by opening a context with the content and - closing it immediately (no deltas). Derives and traces spans from the same - stream. Returns the last text segment's text + usage. - - Index-keyed routing: each Start(index=i) opens a context stored in - ctx_map[i]; Delta(index=i) routes to ctx_map.get(i); Done(index=i) closes - and removes ctx_map[i]. Events with index is None are skipped. The finally - block closes all remaining open contexts. - - final_text last-segment semantics: a new Start(TextContent) resets - final_text_parts so that multi-step turns return the LAST text segment. - Full(TextContent) also overwrites final_text_parts (same semantics). - - created_at is forwarded to every streaming_task_message_context call so - callers can back-date message timestamps. - - Mirrors the open/close/stream_update pattern from - src/agentex/lib/adk/_modules/_langgraph_turn.py: - - context opened via streaming_task_message_context(...).__aenter__() - - context closed via ctx.close() (not __aexit__) - - deltas pushed as StreamTaskMessageDelta with parent_task_message set - from ctx.task_message - - For async + temporal agents (call from inside an activity). - """ - if streaming is None: - from agentex.lib import adk - - streaming = adk.streaming - - deriver = SpanDeriver() if tracer is not None else None - final_text_parts: list[str] = [] - ctx_map: dict[int, Any] = {} - - async def _close_all() -> None: - # Guard each close independently: a failure on one context (e.g. a - # backend hiccup during teardown) must not abandon the remaining open - # contexts, otherwise their task messages would never be finalized. - for ctx in list(ctx_map.values()): - try: - await ctx.close() - except Exception as exc: - logger.warning("[harness.auto_send] context close failed during teardown: %s", exc) - ctx_map.clear() - - try: - async for event in events: - if deriver is not None and tracer is not None: - for signal in deriver.observe(event): - await tracer.handle(signal) - - if isinstance(event, StreamTaskMessageStart): - if event.index is None: - continue - i = event.index - # Reset final_text_parts when a new text segment starts - if isinstance(event.content, TextContent): - final_text_parts = [] - ctx = streaming.streaming_task_message_context( - task_id=task_id, - initial_content=event.content, - created_at=created_at, - ) - ctx_map[i] = await ctx.__aenter__() - - elif isinstance(event, StreamTaskMessageDelta): - if event.index is None: - continue - ctx = ctx_map.get(event.index) - if ctx is not None and event.delta is not None: - # Reconstruct the delta with parent_task_message set from - # the context's task_message (mirrors the legacy - # _langgraph_async streaming helper, now in _langgraph_turn.py). - delta_with_parent = StreamTaskMessageDelta( - parent_task_message=ctx.task_message, - delta=event.delta, - type="delta", - index=event.index, - ) - await ctx.stream_update(delta_with_parent) - if isinstance(event.delta, TextDelta) and event.delta.text_delta: - final_text_parts.append(event.delta.text_delta) - - elif isinstance(event, StreamTaskMessageDone): - if event.index is None: - continue - ctx = ctx_map.pop(event.index, None) - if ctx is not None: - await ctx.close() - - elif isinstance(event, StreamTaskMessageFull): - # Full messages: post the full message by opening a context - # with the content and closing it immediately (no deltas; - # StreamingTaskMessageContext.close() persists initial_content - # when the accumulator is empty). Use async with so the context - # is closed even if close() raises (__aexit__ delegates to - # close()). - # Full(TextContent) also resets final_text_parts for - # last-segment semantics. - if isinstance(event.content, TextContent): - final_text_parts = [event.content.content] - async with streaming.streaming_task_message_context( - task_id=task_id, - initial_content=event.content, - created_at=created_at, - ): - pass - - finally: - await _close_all() - if deriver is not None and tracer is not None: - for signal in deriver.flush(): - await tracer.handle(signal) - - return TurnResult(final_text="".join(final_text_parts), usage=usage or TurnUsage()) diff --git a/src/agentex/lib/core/harness/emitter.py b/src/agentex/lib/core/harness/emitter.py deleted file mode 100644 index 5b56793bf..000000000 --- a/src/agentex/lib/core/harness/emitter.py +++ /dev/null @@ -1,80 +0,0 @@ -"""UnifiedEmitter: the single facade agent authors use for either delivery mode.""" - -from __future__ import annotations - -from typing import AsyncGenerator -from datetime import datetime - -from agentex.lib.core.harness.types import TurnResult, HarnessTurn, StreamTaskMessage -from agentex.lib.core.harness.tracer import SpanTracer -from agentex.lib.core.harness.auto_send import auto_send -from agentex.lib.core.harness.yield_delivery import yield_events - - -class UnifiedEmitter: - """Ties trace context + chosen delivery together. - - Tracing modes (the `tracer` arg): - - tracer=None (default): auto-construct a SpanTracer if `trace_id` is present. - - tracer=False: disable tracing entirely, regardless of `trace_id`. - - tracer=: use the supplied instance. - - `tracing` and `streaming` are injection escape-hatches for tests/advanced - use; leave them None in production so the real adk modules are used. - """ - - tracer: SpanTracer | None - - def __init__( - self, - task_id: str, - trace_id: str | None, - parent_span_id: str | None, - tracer: SpanTracer | bool | None = None, - tracing: object | None = None, - streaming: object | None = None, - ): - self.task_id = task_id - self.trace_id = trace_id - self.parent_span_id = parent_span_id - self._streaming = streaming - if tracer is False: - self.tracer = None - elif isinstance(tracer, SpanTracer): - self.tracer = tracer - elif trace_id: - self.tracer = SpanTracer( - trace_id=trace_id, - parent_span_id=parent_span_id, - task_id=task_id, - tracing=tracing, - ) - else: - self.tracer = None - - async def yield_turn(self, turn: HarnessTurn) -> AsyncGenerator[StreamTaskMessage, None]: - """Sync HTTP ACP delivery: forward events, trace as side effect.""" - async for event in yield_events(turn.events, tracer=self.tracer): - yield event - - async def auto_send_turn(self, turn: HarnessTurn, created_at: datetime | None = None) -> TurnResult: - """Async/temporal delivery: push to the task stream, return TurnResult. - - Pass `created_at` (e.g. `workflow.now()` under Temporal) to stamp the - turn's messages with a deterministic timestamp; it is forwarded to the - streaming contexts. Default None preserves server-side timestamps. - """ - # `turn.usage()` is only valid AFTER `turn.events` is exhausted (the - # HarnessTurn single-pass contract: real turns populate usage while the - # stream is consumed). So drive delivery first, then read usage โ€” do NOT - # pass `usage=turn.usage()` eagerly here (that would capture the empty - # default before the stream runs). - result = await auto_send( - turn.events, - task_id=self.task_id, - tracer=self.tracer, - streaming=self._streaming, - created_at=created_at, - ) - result.usage = turn.usage() - return result diff --git a/src/agentex/lib/core/harness/span_derivation.py b/src/agentex/lib/core/harness/span_derivation.py deleted file mode 100644 index c0ed6ee90..000000000 --- a/src/agentex/lib/core/harness/span_derivation.py +++ /dev/null @@ -1,173 +0,0 @@ -"""Pure reducer: canonical StreamTaskMessage* stream -> span open/close signals. - -Has no dependency on adk; unit-testable in isolation. Delivery adapters feed it -every event and act on the returned signals. -""" - -from __future__ import annotations - -import json -from dataclasses import dataclass - -from agentex.lib.core.harness.types import OpenSpan, CloseSpan, SpanSignal, StreamTaskMessage -from agentex.types.tool_request_delta import ToolRequestDelta -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.types.reasoning_content_delta import ReasoningContentDelta -from agentex.types.reasoning_summary_delta import ReasoningSummaryDelta - - -@dataclass -class _ToolReqMeta: - tool_call_id: str - name: str - arguments: dict[str, object] - args_buf: str = "" # accumulated streamed argument fragments - - -class SpanDeriver: - """Stateful reducer over the canonical stream. - - Tool span: open on Done of a ToolRequestContent index; close on matching - ToolResponseContent by tool_call_id. Reasoning span: open on - Start(ReasoningContent); close on that index's Done. - - Deliberate contracts: - - A `Full(ToolResponseContent)` whose tool_call_id was never opened is - ignored (no CloseSpan emitted). - - A `Done` for an index that was never a tool_request/reasoning Start is - ignored (no signal emitted). - - Events with `index is None` are skipped entirely; without a stable index - they cannot be reliably paired, and aliasing them to a sentinel would - let unrelated None-indexed events cross-match. - - `flush()` closes anything still open as incomplete; unclosed tool spans - are emitted in the order they were opened. - """ - - def __init__(self) -> None: - self._tool_by_index: dict[int, _ToolReqMeta] = {} - self._reasoning_index_open: set[int] = set() - # accumulated reasoning text per open reasoning index, recorded as the - # span output on close (deltas carry the chain-of-thought / summary text). - self._reasoning_text: dict[int, str] = {} - # insertion-ordered set of open tool_call_ids (dict keys preserve order) - self._open_tool_ids: dict[str, None] = {} - - def observe(self, event: StreamTaskMessage) -> list[SpanSignal]: - if isinstance(event, StreamTaskMessageStart): - return self._on_start(event) - if isinstance(event, StreamTaskMessageDelta): - return self._on_delta(event) - if isinstance(event, StreamTaskMessageFull): - return self._on_full(event) - if isinstance(event, StreamTaskMessageDone): - return self._on_done(event) - return [] - - def flush(self) -> list[SpanSignal]: - """Close anything still open at end of stream, marked incomplete.""" - signals: list[SpanSignal] = [] - for tcid in list(self._open_tool_ids): - signals.append(CloseSpan(key=tcid, output=None, is_complete=False)) - self._open_tool_ids.clear() - for idx in sorted(self._reasoning_index_open): - text = self._reasoning_text.pop(idx, "") - signals.append(CloseSpan(key=f"reasoning:{idx}", output=text or None, is_complete=False)) - self._reasoning_index_open.clear() - self._reasoning_text.clear() - return signals - - def _on_start(self, event: StreamTaskMessageStart) -> list[SpanSignal]: - if event.index is None: - return [] - idx = event.index - content = event.content - if isinstance(content, ToolRequestContent): - self._tool_by_index[idx] = _ToolReqMeta( - tool_call_id=content.tool_call_id, - name=content.name, - arguments=dict(content.arguments or {}), - ) - return [] - if content.type == "reasoning": - self._reasoning_index_open.add(idx) - # Seed from any text already on the Start content โ€” non-streaming - # harnesses may carry the full reasoning up front; deltas append. - summary = getattr(content, "summary", None) or [] - body = getattr(content, "content", None) or [] - self._reasoning_text[idx] = "".join([*summary, *body]) - return [OpenSpan(key=f"reasoning:{idx}", kind="reasoning", name="reasoning", input={})] - return [] - - def _on_delta(self, event: StreamTaskMessageDelta) -> list[SpanSignal]: - if event.index is None: - return [] - idx = event.index - delta = event.delta - if isinstance(delta, ToolRequestDelta): - meta = self._tool_by_index.get(idx) - if meta is not None and delta.arguments_delta: - meta.args_buf += delta.arguments_delta - elif isinstance(delta, ReasoningContentDelta): - if idx in self._reasoning_index_open and delta.content_delta: - self._reasoning_text[idx] = self._reasoning_text.get(idx, "") + delta.content_delta - elif isinstance(delta, ReasoningSummaryDelta): - if idx in self._reasoning_index_open and delta.summary_delta: - self._reasoning_text[idx] = self._reasoning_text.get(idx, "") + delta.summary_delta - return [] - - def _on_full(self, event: StreamTaskMessageFull) -> list[SpanSignal]: - """Handle a Full event. - - A `Full(ToolRequestContent)` opens a tool span (keyed by tool_call_id) - if it is not already open; the matching `Full(ToolResponseContent)` - closes it. This handles harnesses (e.g. LangGraph) that emit tool calls - as a single Full rather than Start+Done. - """ - content = event.content - if isinstance(content, ToolRequestContent): - tcid = content.tool_call_id - if tcid not in self._open_tool_ids: - self._open_tool_ids[tcid] = None - args = dict(content.arguments or {}) - return [OpenSpan(key=tcid, kind="tool", name=content.name, input=args)] - return [] - if isinstance(content, ToolResponseContent): - tcid = content.tool_call_id - if tcid in self._open_tool_ids: - self._open_tool_ids.pop(tcid, None) - return [ - CloseSpan( - key=tcid, - output=content.content, - is_complete=True, - is_error=content.is_error, - ) - ] - return [] - - def _on_done(self, event: StreamTaskMessageDone) -> list[SpanSignal]: - if event.index is None: - return [] - idx = event.index - meta = self._tool_by_index.pop(idx, None) - if meta is not None: - args = meta.arguments - if meta.args_buf: - try: - args = json.loads(meta.args_buf) - except json.JSONDecodeError: - args = {"_raw": meta.args_buf} - self._open_tool_ids[meta.tool_call_id] = None - return [OpenSpan(key=meta.tool_call_id, kind="tool", name=meta.name, input=args)] - if idx in self._reasoning_index_open: - self._reasoning_index_open.discard(idx) - text = self._reasoning_text.pop(idx, "") - return [CloseSpan(key=f"reasoning:{idx}", output=text or None, is_complete=True)] - return [] diff --git a/src/agentex/lib/core/harness/tracer.py b/src/agentex/lib/core/harness/tracer.py deleted file mode 100644 index bf37bad30..000000000 --- a/src/agentex/lib/core/harness/tracer.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Adapter from SpanSignals to adk.tracing spans (best-effort, overridable).""" - -from __future__ import annotations - -from typing import Any - -from agentex.lib.core.harness.types import OpenSpan, CloseSpan, SpanSignal - -try: - from agentex.lib.utils.logging import make_logger - - logger = make_logger(__name__) -except Exception: # ddtrace may be absent in some envs; fall back to stdlib - import logging - - logger = logging.getLogger(__name__) - - -def _as_span_payload(value: Any, *, key: str) -> Any: - """Coerce a span input/output payload into a dict. - - The SGP spans API requires ``input`` and ``output`` to be objects: a scalar - or string is rejected with a 422 and the span is dropped by the async - processor. The SpanDeriver legitimately produces non-dict payloads โ€” the - reasoning span's output is the chain-of-thought string, and some harnesses' - tool results are plain strings โ€” so wrap anything that isn't already a dict - (``None`` passes through unchanged so an absent payload stays absent). - """ - if value is None or isinstance(value, dict): - return value - return {key: value} - - -class SpanTracer: - """Opens/closes adk.tracing child spans in response to span signals. - - `tracing` defaults to the real `adk.tracing` module; inject a fake in tests - or a custom tracer to override. No-op when `trace_id` is falsy. Never raises. - - The real TracingModule.end_span does NOT accept an output kwarg โ€” output is - recorded by mutating span.output before calling end_span, matching the pattern - used throughout the codebase. - - Span-lifecycle contract: the `_open` dict (span key -> span object) is scoped - to a single turn. Pairing is by `key`: - - A duplicate OpenSpan for a key already in `_open` silently replaces the - earlier span; the earlier span is then orphaned (never closed / leaked). - - A CloseSpan for an unknown key is a no-op. - - Unpaired opens accumulate in `_open` for the lifetime of the tracer; since - a tracer is expected to live for one turn, this is bounded and acceptable. - """ - - def __init__( - self, - trace_id: str | None, - parent_span_id: str | None, - tracing: Any = None, - task_id: str | None = None, - ): - self.trace_id = trace_id - self.parent_span_id = parent_span_id - self.task_id = task_id - if tracing is None: - from agentex.lib import adk - - tracing = adk.tracing - self._tracing = tracing - self._open: dict[str, Any] = {} # span key -> span object - - async def handle(self, signal: SpanSignal) -> None: - if not self.trace_id: - return - try: - if isinstance(signal, OpenSpan): - span = await self._tracing.start_span( - trace_id=self.trace_id, - name=signal.name, - input=_as_span_payload(signal.input, key="input"), - parent_id=self.parent_span_id, - task_id=self.task_id, - ) - if span is not None: - self._open[signal.key] = span - elif isinstance(signal, CloseSpan): - span = self._open.pop(signal.key, None) - if span is not None: - # Output is recorded by mutating span.output before end_span. - # The real TracingModule.end_span signature is: - # end_span(trace_id, span, start_to_close_timeout, heartbeat_timeout, retry_policy) - # It does not accept an output= kwarg. - span.output = _as_span_payload(signal.output, key="output") - # Tool failure status (ToolResponseContent.is_error) is recorded - # on span.data when the harness reports one; Span has no dedicated - # error field. None means no status was reported, so leave data alone. - if signal.is_error is not None: - data = span.data if isinstance(span.data, dict) else {} - span.data = {**data, "is_error": signal.is_error} - await self._tracing.end_span( - trace_id=self.trace_id, - span=span, - ) - except Exception as exc: # best-effort: tracing never breaks delivery - logger.warning("[harness.tracer] span signal failed: %s", exc) diff --git a/src/agentex/lib/core/harness/types.py b/src/agentex/lib/core/harness/types.py deleted file mode 100644 index 74e0dc314..000000000 --- a/src/agentex/lib/core/harness/types.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Types for the unified harness surface.""" - -from __future__ import annotations - -from typing import Any, Union, Literal, Protocol, AsyncIterator, runtime_checkable -from dataclasses import field, dataclass - -from pydantic import BaseModel, ConfigDict - -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) - -# The canonical stream element. Taps yield these; delivery adapters consume them. -StreamTaskMessage = Union[ - StreamTaskMessageStart, - StreamTaskMessageDelta, - StreamTaskMessageFull, - StreamTaskMessageDone, -] - -SpanKind = Literal["tool", "reasoning", "subagent"] - - -@dataclass -class OpenSpan: - """Signal to open a child span. `key` pairs an open with its close.""" - - key: str - kind: SpanKind - name: str - input: dict[str, Any] = field(default_factory=dict) - - -@dataclass -class CloseSpan: - """Signal to close the span previously opened with the same `key`.""" - - key: str - output: Any = None - is_complete: bool = True # False when closed by flush() without a result - is_error: bool | None = None # tool failure status; None when the harness reports no status - - -SpanSignal = Union[OpenSpan, CloseSpan] - - -class TurnUsage(BaseModel): - """Harness-independent turn usage/cost, attached to the turn span. - - Token field names align with agentex.lib.core.observability.llm_metrics. - """ - - model_config = ConfigDict(from_attributes=True, populate_by_name=True) - - model: str | None = None - input_tokens: int | None = None - output_tokens: int | None = None - cached_input_tokens: int | None = None - reasoning_tokens: int | None = None - total_tokens: int | None = None - cost_usd: float | None = None - duration_ms: int | None = None - # num_llm_calls is provider-reported and may be absent (None = "not - # reported"). num_tool_calls / num_reasoning_blocks are counted locally from - # the observed stream, so 0 is always a real count. - num_llm_calls: int | None = None - num_tool_calls: int = 0 - num_reasoning_blocks: int = 0 - - -class TurnResult(BaseModel): - """Returned to the caller after a turn is delivered.""" - - model_config = ConfigDict(from_attributes=True, populate_by_name=True) - - final_text: str = "" - usage: TurnUsage = TurnUsage() - - -@runtime_checkable -class HarnessTurn(Protocol): - """A single harness turn: a canonical stream plus its normalized usage. - - Python async generators cannot cleanly return a value to their consumer, so - a tap exposes usage via `usage()` (valid only after `events` is exhausted) - rather than via StopAsyncIteration. - """ - - @property - def events(self) -> AsyncIterator[StreamTaskMessage]: ... - - def usage(self) -> TurnUsage: ... diff --git a/src/agentex/lib/core/harness/yield_delivery.py b/src/agentex/lib/core/harness/yield_delivery.py deleted file mode 100644 index 69b39f152..000000000 --- a/src/agentex/lib/core/harness/yield_delivery.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Yield delivery: pass the canonical stream through, tracing as a side effect.""" - -from __future__ import annotations - -from typing import AsyncIterator, AsyncGenerator - -from agentex.lib.core.harness.types import StreamTaskMessage -from agentex.lib.core.harness.tracer import SpanTracer -from agentex.lib.core.harness.span_derivation import SpanDeriver - - -async def yield_events( - events: AsyncIterator[StreamTaskMessage], - tracer: SpanTracer | None = None, -) -> AsyncGenerator[StreamTaskMessage, None]: - """Forward each event to the caller; derive + trace spans as a side effect. - - For sync HTTP ACP agents that yield events back over the response. When - `tracer` is None, this is a pure passthrough. - """ - deriver = SpanDeriver() if tracer is not None else None - try: - async for event in events: - if deriver is not None and tracer is not None: - for signal in deriver.observe(event): - await tracer.handle(signal) - yield event - finally: - if deriver is not None and tracer is not None: - for signal in deriver.flush(): - await tracer.handle(signal) diff --git a/src/agentex/lib/core/observability/__init__.py b/src/agentex/lib/core/observability/__init__.py 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 416077e8f..000000000 --- a/src/agentex/lib/core/services/adk/providers/litellm.py +++ /dev/null @@ -1,277 +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__) - - -def _stream_kwargs_with_usage(llm_config: LLMConfig) -> dict: - """Completion kwargs with usage reporting enabled on the final stream chunk. - - litellm only reports usage for streaming calls when - ``stream_options={"include_usage": True}`` is set; default it on so usage - reaches the span. Callers can still opt out by explicitly passing - ``stream_options={"include_usage": False}``. - """ - kwargs = llm_config.model_dump() - stream_options = kwargs.get("stream_options") or {} - kwargs["stream_options"] = {"include_usage": True, **stream_options} - return kwargs - - -class LiteLLMService: - def __init__( - self, - agentex_client: AsyncAgentex, - streaming_service: StreamingService, - tracer: AsyncTracer, - llm_gateway: LiteLLMGateway | None = None, - ): - self.agentex_client = agentex_client - self.llm_gateway = llm_gateway - self.streaming_service = streaming_service - self.tracer = tracer - - async def chat_completion( - self, - llm_config: LLMConfig, - trace_id: str | None = None, - parent_span_id: str | None = None, - ) -> Completion: - trace = self.tracer.trace(trace_id) - async with trace.span( - parent_id=parent_span_id, - name="chat_completion", - input=llm_config.model_dump(), - ) as span: - heartbeat_if_in_workflow("chat completion") - if self.llm_gateway is None: - raise ValueError("LLM Gateway is not set") - completion = await self.llm_gateway.acompletion(**llm_config.model_dump()) - if span: - span.output = completion.model_dump() - return completion - - async def chat_completion_auto_send( - self, - task_id: str, - llm_config: LLMConfig, - trace_id: str | None = None, - parent_span_id: str | None = None, - created_at: datetime | None = None, - ) -> TaskMessage | None: - """ - Chat completion with automatic TaskMessage creation. This does not stream the completion. To stream use chat_completion_stream_auto_send. - - Args: - task_id (str): The ID of the task to run the agent for. - llm_config (LLMConfig): The configuration for the LLM (must have stream=True). - - Returns: - TaskMessage: A TaskMessage object - """ - - if llm_config.stream: - raise ValueError( - "LLM config must not have stream=True. To stream use `chat_completion_stream` or `chat_completion_stream_auto_send`." - ) - - if self.llm_gateway is None: - raise ValueError("LLM Gateway is not set") - - trace = self.tracer.trace(trace_id) - async with trace.span( - parent_id=parent_span_id, - name="chat_completion_auto_send", - input=llm_config.model_dump(), - ) as span: - heartbeat_if_in_workflow("chat completion auto send") - - async with self.streaming_service.streaming_task_message_context( - task_id=task_id, - initial_content=TextContent( - author="agent", - content="", - format="markdown", - ), - created_at=created_at, - ) as streaming_context: - completion = await self.llm_gateway.acompletion(**llm_config.model_dump()) - if completion.choices and len(completion.choices) > 0 and completion.choices[0].message: - final_content = TextContent( - author="agent", - content=completion.choices[0].message.content or "", - format="markdown", - ) - await streaming_context.stream_update( - update=StreamTaskMessageFull( - parent_task_message=streaming_context.task_message, - content=final_content, - type="full", - ), - ) - else: - raise ValueError("No completion message returned from LLM") - - if span: - if streaming_context.task_message: - output = streaming_context.task_message.model_dump() - # Per-call usage for billing; deduped against any turn aggregate - if completion.usage is not None: - output["usage"] = completion.usage.model_dump() - span.output = output - return streaming_context.task_message if streaming_context.task_message else None - - async def chat_completion_stream( - self, - llm_config: LLMConfig, - trace_id: str | None = None, - parent_span_id: str | None = None, - ) -> AsyncGenerator[Completion, None]: - """ - Stream chat completion chunks using LiteLLM. - - Args: - llm_config (LLMConfig): The configuration for the LLM (must have stream=True). - trace_id (Optional[str]): The trace ID for tracing. - parent_span_id (Optional[str]): The parent span ID for tracing. - - Returns: - AsyncGenerator[Completion, None]: Generator yielding completion chunks - - Raises: - ValueError: If called from within a Temporal workflow or if stream=False - """ - if not llm_config.stream: - raise ValueError("LLM config must have stream=True for streaming") - - if self.llm_gateway is None: - raise ValueError("LLM Gateway is not set") - - completion_kwargs = _stream_kwargs_with_usage(llm_config) - trace = self.tracer.trace(trace_id) - async with trace.span( - parent_id=parent_span_id, - name="chat_completion_stream", - input=completion_kwargs, - ) as span: - # Direct streaming outside temporal - yield each chunk as it comes - chunks: list[Completion] = [] - async for chunk in self.llm_gateway.acompletion_stream(**completion_kwargs): - chunks.append(chunk) - yield chunk - if span: - # The usage-bearing final chunk survives concat, so the dumped - # completion carries usage for billing - span.output = concat_completion_chunks(chunks).model_dump() - - async def chat_completion_stream_auto_send( - self, - task_id: str, - llm_config: LLMConfig, - trace_id: str | None = None, - parent_span_id: str | None = None, - created_at: datetime | None = None, - ) -> TaskMessage | None: - """ - Stream chat completion with automatic TaskMessage creation and streaming. - - Args: - task_id (str): The ID of the task to run the agent for. - llm_config (LLMConfig): The configuration for the LLM (must have stream=True). - - Returns: - TaskMessage: A TaskMessage object - """ - heartbeat_if_in_workflow("chat completion stream") - - if self.llm_gateway is None: - raise ValueError("LLM Gateway is not set") - - if not llm_config.stream: - llm_config.stream = True - - completion_kwargs = _stream_kwargs_with_usage(llm_config) - trace = self.tracer.trace(trace_id) - async with trace.span( - parent_id=parent_span_id, - name="chat_completion_stream_auto_send", - input=completion_kwargs, - ) as span: - # Use streaming context manager - async with self.streaming_service.streaming_task_message_context( - task_id=task_id, - initial_content=TextContent( - author="agent", - content="", - format="markdown", - ), - created_at=created_at, - ) as streaming_context: - # Get the streaming response - chunks = [] - async for response in self.llm_gateway.acompletion_stream(**completion_kwargs): - heartbeat_if_in_workflow("chat completion streaming") - # Store every chunk for final message assembly, including - # the usage-only final chunk, which has no choices - chunks.append(response) - if response.choices and len(response.choices) > 0 and response.choices[0].delta: - delta = response.choices[0].delta.content - if delta: - # Stream the chunk via the context manager - await streaming_context.stream_update( - update=StreamTaskMessageDelta( - parent_task_message=streaming_context.task_message, - delta=TextDelta(text_delta=delta, type="text"), - type="delta", - ), - ) - heartbeat_if_in_workflow("content chunk streamed") - - # Update the final message content - complete_message = concat_completion_chunks(chunks) - if complete_message and complete_message.choices and complete_message.choices[0].message: - final_content = TextContent( - author="agent", - content=complete_message.choices[0].message.content or "", - format="markdown", - ) - await streaming_context.stream_update( - update=StreamTaskMessageFull( - parent_task_message=streaming_context.task_message, - content=final_content, - type="full", - ), - ) - - heartbeat_if_in_workflow("chat completion stream complete") - - if span: - if streaming_context.task_message: - output = streaming_context.task_message.model_dump() - # Per-call usage for billing; deduped against any turn aggregate - if complete_message.usage is not None: - output["usage"] = complete_message.usage.model_dump() - span.output = output - - return streaming_context.task_message if streaming_context.task_message else None diff --git a/src/agentex/lib/core/services/adk/providers/openai.py b/src/agentex/lib/core/services/adk/providers/openai.py deleted file mode 100644 index a2513ea01..000000000 --- a/src/agentex/lib/core/services/adk/providers/openai.py +++ /dev/null @@ -1,917 +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 ( - ResponseFunctionWebSearch, - ResponseCodeInterpreterToolCall, -) - -# Local imports -from agentex import AsyncAgentex -from agentex.lib.utils import logging -from agentex.lib.utils.mcp import redact_mcp_server_params -from agentex.lib.utils.temporal import heartbeat_if_in_workflow -from agentex.lib.core.tracing.tracer import AsyncTracer -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.types.task_message_update import StreamTaskMessageFull -from agentex.types.task_message_content import ( - TextContent, - ToolRequestContent, - ToolResponseContent, -) -from agentex.lib.core.services.adk.streaming import StreamingService - -logger = logging.make_logger(__name__) - - -@asynccontextmanager -async def mcp_server_context( - mcp_server_params: list[StdioServerParameters], - mcp_timeout_seconds: int | None = None, -): - """Context manager for MCP servers.""" - servers = [] - for params in mcp_server_params: - server = MCPServerStdio( - name=f"Server: {params.command}", - params=params.model_dump(), - cache_tools_list=True, - client_session_timeout_seconds=mcp_timeout_seconds, - ) - servers.append(server) - - async with AsyncExitStack() as stack: - for server in servers: - await stack.enter_async_context(server) - yield servers - - -def _make_created_at_dispenser(initial: datetime | None) -> Callable[[], datetime | None]: - # Returns a closure that yields the workflow-supplied created_at exactly - # once (on the first call), then None forever after. Used to stamp the - # first agent message of a turn with workflow.now() while letting - # subsequent messages fall back to server wall-clock โ€” see the call sites - # in run_agent_auto_send / run_agent_streamed_auto_send for context. - pending: list[datetime | None] = [initial] - - def take() -> datetime | None: - value = pending[0] - pending[0] = None - return value - - return take - - -class OpenAIService: - """Service for OpenAI agent operations using the agents library.""" - - def __init__( - self, - agentex_client: AsyncAgentex | None = None, - streaming_service: StreamingService | None = None, - tracer: AsyncTracer | None = None, - ): - self.agentex_client = agentex_client - self.streaming_service = streaming_service - self.tracer = tracer - - def _extract_tool_call_info(self, tool_call_item: Any) -> tuple[str, str, dict[str, Any]]: - """ - Extract call_id, tool_name, and tool_arguments from a tool call item. - - Args: - tool_call_item: The tool call item to process - - Returns: - A tuple of (call_id, tool_name, tool_arguments) - """ - # Generic handling for different tool call types - # Try 'call_id' first, then 'id', then generate placeholder - if hasattr(tool_call_item, "call_id"): - call_id = tool_call_item.call_id - elif hasattr(tool_call_item, "id"): - call_id = tool_call_item.id - else: - call_id = f"unknown_call_{id(tool_call_item)}" - logger.warning( - f"Warning: Tool call item {type(tool_call_item)} has " - f"neither 'call_id' nor 'id' attribute, using placeholder: " - f"{call_id}" - ) - - if isinstance(tool_call_item, ResponseFunctionWebSearch): - tool_name = "web_search" - tool_arguments = {"action": tool_call_item.action.model_dump(), "status": tool_call_item.status} - elif isinstance(tool_call_item, ResponseCodeInterpreterToolCall): - tool_name = "code_interpreter" - tool_arguments = {"code": tool_call_item.code, "status": tool_call_item.status} - else: - # Generic handling for any tool call type - tool_name = getattr(tool_call_item, "name", type(tool_call_item).__name__) - tool_arguments = tool_call_item.model_dump() - - return call_id, tool_name, tool_arguments - - def _extract_tool_response_info(self, tool_call_map: dict[str, Any], tool_output_item: Any) -> tuple[str, str, str]: - """ - Extract call_id, tool_name, and content from a tool output item. - - Args: - tool_call_map: Map of call_ids to tool_call items - tool_output_item: The tool output item to process - - Returns: - A tuple of (call_id, tool_name, content) - """ - # Extract call_id and content from the tool_output_item - # Handle both dictionary access and attribute access - if hasattr(tool_output_item, "get") and callable(tool_output_item.get): - # Dictionary-like access - call_id = tool_output_item["call_id"] - content = tool_output_item["output"] - else: - # Attribute access for structured objects - call_id = getattr(tool_output_item, "call_id", "") - content = getattr(tool_output_item, "output", "") - - # Get the name from the tool call map using generic approach - tool_call = tool_call_map[call_id] - if hasattr(tool_call, "name"): - tool_name = tool_call.name - elif hasattr(tool_call, "type"): - tool_name = tool_call.type - else: - tool_name = type(tool_call).__name__ - - return call_id, tool_name, content - - async def run_agent( - self, - input_list: list[dict[str, Any]], - mcp_server_params: list[StdioServerParameters], - agent_name: str, - agent_instructions: str, - trace_id: str | None = None, - parent_span_id: str | None = None, - handoff_description: str | None = None, - handoffs: list[BaseModel] | None = None, - model: str | None = None, - model_settings: BaseModel | None = None, - tools: list[BaseModel] | None = None, - output_type: type[Any] | None = None, - tool_use_behavior: ( - Literal["run_llm_again", "stop_on_first_tool"] | StopAtTools | ToolsToFinalOutputFunction - ) = "run_llm_again", - mcp_timeout_seconds: int | None = None, - input_guardrails: list[InputGuardrail] | None = None, - output_guardrails: list[OutputGuardrail] | None = None, - max_turns: int | None = None, - previous_response_id: str | None = None, # noqa: ARG002 - ) -> RunResult: - """ - Run an agent without streaming or TaskMessage creation. - - Args: - input_list: List of input data for the agent. - mcp_server_params: MCP server parameters for the agent. - agent_name: The name of the agent to run. - agent_instructions: Instructions for the agent. - trace_id: Optional trace ID for tracing. - parent_span_id: Optional parent span ID for tracing. - handoff_description: Optional description of the handoff. - handoffs: Optional list of handoffs. - model: Optional model to use. - model_settings: Optional model settings. - tools: Optional list of tools. - output_type: Optional output type. - tool_use_behavior: Optional tool use behavior. - mcp_timeout_seconds: Optional param to set the timeout threshold - for the MCP servers. Defaults to 5 seconds. - input_guardrails: Optional list of input guardrails to run on - initial user input. - output_guardrails: Optional list of output guardrails to run on - final agent output. - mcp_timeout_seconds: Optional param to set the timeout threshold for the MCP servers. Defaults to 5 seconds. - max_turns: Maximum number of turns the agent can take. Uses Runner's default if None. - Returns: - SerializableRunResult: The result of the agent run. - """ - redacted_params = redact_mcp_server_params(mcp_server_params) - - if self.tracer is None: - raise RuntimeError("Tracer not initialized - ensure tracer is provided to OpenAIService") - trace = self.tracer.trace(trace_id) - async with trace.span( - parent_id=parent_span_id, - name="run_agent", - input={ - "input_list": input_list, - "mcp_server_params": redacted_params, - "agent_name": agent_name, - "agent_instructions": agent_instructions, - "handoff_description": handoff_description, - "handoffs": handoffs, - "model": model, - "model_settings": model_settings, - "tools": tools, - "output_type": output_type, - "tool_use_behavior": tool_use_behavior, - "max_turns": max_turns, - }, - ) as span: - heartbeat_if_in_workflow("run agent") - - async with mcp_server_context(mcp_server_params, mcp_timeout_seconds) as servers: - tools = ( - [ - tool.to_oai_function_tool() if hasattr(tool, "to_oai_function_tool") else tool # type: ignore[attr-defined] - for tool in tools - ] - if tools - else [] - ) - handoffs = [Agent(**handoff.model_dump()) for handoff in handoffs] if handoffs else [] # type: ignore[misc] - - agent_kwargs = { - "name": agent_name, - "instructions": agent_instructions, - "mcp_servers": servers, - "handoff_description": handoff_description, - "handoffs": handoffs, - "model": model, - "tools": tools, - "output_type": output_type, - "tool_use_behavior": tool_use_behavior, - } - if model_settings is not None: - agent_kwargs["model_settings"] = ( - model_settings.to_oai_model_settings() # type: ignore[attr-defined] - if hasattr(model_settings, "to_oai_model_settings") - else model_settings - ) - if input_guardrails is not None: - agent_kwargs["input_guardrails"] = input_guardrails - if output_guardrails is not None: - agent_kwargs["output_guardrails"] = output_guardrails - - agent = Agent(**agent_kwargs) - - # Run without streaming - if max_turns is not None and previous_response_id is not None: - result = await Runner.run( - starting_agent=agent, - input=input_list, - max_turns=max_turns, - previous_response_id=previous_response_id, - ) - elif max_turns is not None: - result = await Runner.run(starting_agent=agent, input=input_list, max_turns=max_turns) - elif previous_response_id is not None: - result = await Runner.run( - starting_agent=agent, input=input_list, previous_response_id=previous_response_id - ) - else: - result = await Runner.run(starting_agent=agent, input=input_list) - - if span: - 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, - created_at: datetime | None = None, - ) -> RunResultStreaming: - """ - Run an agent with streaming enabled and automatic TaskMessage creation. - - Args: - task_id: The ID of the task to run the agent for. - input_list: List of input data for the agent. - mcp_server_params: MCP server parameters for the agent. - agent_name: The name of the agent to run. - agent_instructions: Instructions for the agent. - trace_id: Optional trace ID for tracing. - parent_span_id: Optional parent span ID for tracing. - handoff_description: Optional description of the handoff. - handoffs: Optional list of handoffs. - model: Optional model to use. - model_settings: Optional model settings. - tools: Optional list of tools. - output_type: Optional output type. - tool_use_behavior: Optional tool use behavior. - mcp_timeout_seconds: Optional param to set the timeout threshold - for the MCP servers. Defaults to 5 seconds. - input_guardrails: Optional list of input guardrails to run on - initial user input. - output_guardrails: Optional list of output guardrails to run on - final agent output. - mcp_timeout_seconds: Optional param to set the timeout threshold for the MCP servers. Defaults to 5 seconds. - max_turns: Maximum number of turns the agent can take. Uses Runner's default if None. - - Returns: - RunResultStreaming: The result of the agent run with streaming. - """ - if self.streaming_service is None: - raise ValueError("StreamingService must be available for auto_send methods") - if self.agentex_client is None: - raise ValueError("Agentex client must be provided for auto_send methods") - - if self.tracer is None: - raise RuntimeError("Tracer not initialized - ensure tracer is provided to OpenAIService") - trace = self.tracer.trace(trace_id) - redacted_params = redact_mcp_server_params(mcp_server_params) - - async with trace.span( - parent_id=parent_span_id, - name="run_agent_streamed_auto_send", - input={ - "task_id": task_id, - "input_list": input_list, - "mcp_server_params": redacted_params, - "agent_name": agent_name, - "agent_instructions": agent_instructions, - "handoff_description": handoff_description, - "handoffs": handoffs, - "model": model, - "model_settings": model_settings, - "tools": tools, - "output_type": output_type, - "tool_use_behavior": tool_use_behavior, - "max_turns": max_turns, - }, - ) as span: - heartbeat_if_in_workflow("run agent streamed auto send") - - # created_at is threaded through UnifiedEmitter.auto_send_turn -> - # auto_send -> every streaming_task_message_context call, so the - # first agent message of the turn is stamped with the - # workflow-supplied timestamp (e.g. workflow.now()). - # The dispenser is still used below for guardrail-rejection messages, - # which open their own streaming contexts directly. - _take_created_at = _make_created_at_dispenser(created_at) - - async with mcp_server_context(mcp_server_params, mcp_timeout_seconds) as servers: - tools = ( - [ - tool.to_oai_function_tool() if hasattr(tool, "to_oai_function_tool") else tool # type: ignore[attr-defined] - for tool in tools - ] - if tools - else [] - ) - handoffs = [Agent(**handoff.model_dump()) for handoff in handoffs] if handoffs else [] # type: ignore[misc] - agent_kwargs = { - "name": agent_name, - "instructions": agent_instructions, - "mcp_servers": servers, - "handoff_description": handoff_description, - "handoffs": handoffs, - "model": model, - "tools": tools, - "output_type": output_type, - "tool_use_behavior": tool_use_behavior, - } - if model_settings is not None: - agent_kwargs["model_settings"] = ( - model_settings.to_oai_model_settings() # type: ignore[attr-defined] - if hasattr(model_settings, "to_oai_model_settings") - else model_settings - ) - if input_guardrails is not None: - agent_kwargs["input_guardrails"] = input_guardrails - if output_guardrails is not None: - agent_kwargs["output_guardrails"] = output_guardrails - - agent = Agent(**agent_kwargs) - - # Run with streaming. Forward previous_response_id so callers that - # continue a Responses-API conversation resume the prior response - # instead of silently starting a fresh one (mirrors the non-auto-send - # run_agent_streamed path). - if max_turns is not None and previous_response_id is not None: - result = Runner.run_streamed( - starting_agent=agent, - input=input_list, - max_turns=max_turns, - previous_response_id=previous_response_id, - ) - elif max_turns is not None: - result = Runner.run_streamed(starting_agent=agent, input=input_list, max_turns=max_turns) - elif previous_response_id is not None: - result = Runner.run_streamed( - starting_agent=agent, input=input_list, previous_response_id=previous_response_id - ) - else: - result = Runner.run_streamed(starting_agent=agent, input=input_list) - - # Migrate onto the unified harness surface: wrap the streamed run - # as an OpenAITurn (provider -> canonical StreamTaskMessage* - # adapter) and let UnifiedEmitter.auto_send_turn drive delivery + - # tracing + usage. The previous ~270-line inline loop that hand- - # rolled per-item streaming contexts, reasoning handling, and - # span derivation now lives in the shared harness modules. - # Imported lazily: openai_turn pulls in agentex.lib.adk, which - # imports this service module, so an eager import would create a - # circular import at package init. - from agentex.lib.adk.providers._modules.openai_turn import OpenAITurn - - turn = OpenAITurn(result=result, model=model) - emitter = UnifiedEmitter( - task_id=task_id, - trace_id=trace_id, - parent_span_id=parent_span_id, - tracer=self.tracer, - streaming=self.streaming_service, - ) - - try: - await emitter.auto_send_turn(turn, created_at=created_at) - - except InputGuardrailTripwireTriggered as e: - # Handle guardrail trigger by sending a rejection message - rejection_message = "I'm sorry, but I cannot process this request due to a guardrail. Please try a different question." - - # Try to extract rejection message from the guardrail result - if hasattr(e, "guardrail_result") and hasattr(e.guardrail_result, "output"): - output_info = getattr(e.guardrail_result.output, "output_info", {}) - if isinstance(output_info, dict) and "rejection_message" in output_info: - rejection_message = output_info["rejection_message"] - elif hasattr(e.guardrail_result, "guardrail"): - # Fall back to using guardrail name if no custom message - triggered_guardrail_name = getattr(e.guardrail_result.guardrail, "name", None) - if triggered_guardrail_name: - rejection_message = f"I'm sorry, but I cannot process this request. The '{triggered_guardrail_name}' guardrail was triggered." - - # Create and send the rejection message as a TaskMessage - async with self.streaming_service.streaming_task_message_context( - task_id=task_id, - initial_content=TextContent( - author="agent", - content=rejection_message, - ), - created_at=_take_created_at(), - ) as streaming_context: - # Send the full message - await streaming_context.stream_update( - update=StreamTaskMessageFull( - parent_task_message=streaming_context.task_message, - content=TextContent( - author="agent", - content=rejection_message, - ), - type="full", - ), - ) - - # Re-raise to let the activity handle it - raise - - except OutputGuardrailTripwireTriggered as e: - # Handle output guardrail trigger by sending a rejection message - rejection_message = "I'm sorry, but I cannot provide this response due to a guardrail. Please try a different question." - - # Try to extract rejection message from the guardrail result - if hasattr(e, "guardrail_result") and hasattr(e.guardrail_result, "output"): - output_info = getattr(e.guardrail_result.output, "output_info", {}) - if isinstance(output_info, dict) and "rejection_message" in output_info: - rejection_message = output_info["rejection_message"] - elif hasattr(e.guardrail_result, "guardrail"): - # Fall back to using guardrail name if no custom message - triggered_guardrail_name = getattr(e.guardrail_result.guardrail, "name", None) - if triggered_guardrail_name: - rejection_message = f"I'm sorry, but I cannot provide this response. The '{triggered_guardrail_name}' guardrail was triggered." - - # Create and send the rejection message as a TaskMessage - async with self.streaming_service.streaming_task_message_context( - task_id=task_id, - initial_content=TextContent( - author="agent", - content=rejection_message, - ), - created_at=_take_created_at(), - ) as streaming_context: - # Send the full message - await streaming_context.stream_update( - update=StreamTaskMessageFull( - parent_task_message=streaming_context.task_message, - content=TextContent( - author="agent", - content=rejection_message, - ), - type="full", - ), - ) - - # Re-raise to let the activity handle it - raise - - if span: - 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 93012b933..000000000 --- a/src/agentex/lib/core/services/adk/state.py +++ /dev/null @@ -1,127 +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: - # Send task_id/agent_id in the body for backends predating - # scale-agentex#278, which still require them (newer ones ignore them). - state_model = await self._agentex_client.states.update( - state_id=state_id, - state=state, - extra_body={"task_id": task_id, "agent_id": agent_id}, - ) - if span: - span.output = state_model.model_dump() - return state_model - - async def delete_state( - self, - state_id: str, - trace_id: str | None = None, - parent_span_id: str | None = None, - ) -> State: - trace = self._tracer.trace(trace_id) - async with trace.span( - parent_id=parent_span_id, - name="delete_state", - input={"state_id": state_id}, - ) as span: - state = await self._agentex_client.states.delete(state_id) - if span: - span.output = state.model_dump() - return state diff --git a/src/agentex/lib/core/services/adk/streaming.py b/src/agentex/lib/core/services/adk/streaming.py deleted file mode 100644 index 33ca7bc1c..000000000 --- a/src/agentex/lib/core/services/adk/streaming.py +++ /dev/null @@ -1,573 +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: - # Re-check under the lock: a concurrent close() (e.g. from a racing - # Full) may have drained and shut down the ticker after the check - # above but before we acquired the lock. Appending now would strand - # the delta in a dead buffer, never published. - if self._closed: - return - self._buf.append(update) - self._buf_chars += _delta_char_len(update.delta) - if not self._first_flushed or self._buf_chars >= self.MAX_BUFFERED_CHARS: - self._first_flushed = True - self._flush_signal.set() - - async def _run(self) -> None: - try: - while True: - try: - await asyncio.wait_for(self._flush_signal.wait(), timeout=self.FLUSH_INTERVAL_S) - except asyncio.TimeoutError: - pass - async with self._lock: - self._flush_signal.clear() - drained = self._drain_locked() - for u in drained: - try: - await self._on_flush(u) - except Exception as e: - logger.exception(f"CoalescingBuffer flush failed: {e}") - # Check _closed *after* draining so close() always gets a final - # in-loop flush pass. Exiting here (instead of being cancelled - # mid-flush) guarantees each in-flight item is published exactly - # once โ€” close()'s final drain then only picks up items added - # after the last lock release. - if self._closed: - return - except asyncio.CancelledError: - pass - - async def close(self) -> None: - # Signal the ticker to stop and let it exit naturally after its next - # drain. Cancelling mid-flush would risk re-publishing a delta whose - # Redis write already completed but whose await had not yet returned, - # producing the duplicate-tail symptom seen on the UI stream. - self._closed = True - if self._task is not None: - self._flush_signal.set() - try: - await self._task - except asyncio.CancelledError: - # Propagate if our caller is being cancelled; the task itself - # swallows CancelledError so this only fires on outer cancel. - raise - self._task = None - async with self._lock: - drained = self._drain_locked() - for u in drained: - try: - await self._on_flush(u) - except Exception as e: - logger.exception(f"CoalescingBuffer final flush failed: {e}") - - def _drain_locked(self) -> list[StreamTaskMessageDelta]: - if not self._buf: - return [] - merged = _merge_consecutive(self._buf) - self._buf = [] - self._buf_chars = 0 - return merged - - -class DeltaAccumulator: - def __init__(self): - self._accumulated_deltas: list[TaskMessageDelta] = [] - self._delta_type: Literal["text", "data", "tool_request", "tool_response", "reasoning"] | None = None - # For reasoning, we need to track both summary and content deltas - self._reasoning_summaries: dict[int, str] = {} - self._reasoning_contents: dict[int, str] = {} - - def add_delta(self, delta: TaskMessageDelta): - if self._delta_type is None: - if delta.type == "text": - self._delta_type = "text" - elif delta.type == "data": - self._delta_type = "data" - elif delta.type == "tool_request": - self._delta_type = "tool_request" - elif delta.type == "tool_response": - self._delta_type = "tool_response" - elif delta.type in ["reasoning_summary", "reasoning_content"]: - self._delta_type = "reasoning" - else: - raise ValueError(f"Unknown delta type: {delta.type}") - else: - # For reasoning, we allow both summary and content deltas - if self._delta_type == "reasoning": - if delta.type not in ["reasoning_summary", "reasoning_content"]: - raise ValueError(f"Expected reasoning delta but got: {delta.type}") - elif self._delta_type != delta.type: - raise ValueError(f"Delta type mismatch: {self._delta_type} != {delta.type}") - - # Handle reasoning deltas specially - if delta.type == "reasoning_summary": - if isinstance(delta, ReasoningSummaryDelta): - if delta.summary_index not in self._reasoning_summaries: - self._reasoning_summaries[delta.summary_index] = "" - self._reasoning_summaries[delta.summary_index] += delta.summary_delta or "" - elif delta.type == "reasoning_content": - if isinstance(delta, ReasoningContentDelta): - if delta.content_index not in self._reasoning_contents: - self._reasoning_contents[delta.content_index] = "" - self._reasoning_contents[delta.content_index] += delta.content_delta or "" - else: - self._accumulated_deltas.append(delta) - - def convert_to_content(self) -> TaskMessageContent: - if self._delta_type == "text": - # Type assertion: we know all deltas are TextDelta when _delta_type is TEXT - text_deltas = [delta for delta in self._accumulated_deltas if isinstance(delta, TextDelta)] - text_content_str = "".join([delta.text_delta or "" for delta in text_deltas]) - return TextContent( - author="agent", - content=text_content_str, - ) - elif self._delta_type == "data": - # Type assertion: we know all deltas are DataDelta when _delta_type is DATA - data_deltas = [delta for delta in self._accumulated_deltas if isinstance(delta, DataDelta)] - data_content_str = "".join([delta.data_delta or "" for delta in data_deltas]) - try: - data = json.loads(data_content_str) - except json.JSONDecodeError as e: - raise ValueError(f"Accumulated data content is not valid JSON: {data_content_str}") from e - return DataContent( - author="agent", - data=data, - ) - elif self._delta_type == "tool_request": - # Type assertion: we know all deltas are ToolRequestDelta when _delta_type is TOOL_REQUEST - tool_request_deltas = [delta for delta in self._accumulated_deltas if isinstance(delta, ToolRequestDelta)] - arguments_content_str = "".join([delta.arguments_delta or "" for delta in tool_request_deltas]) - try: - arguments = json.loads(arguments_content_str) - except json.JSONDecodeError as e: - raise ValueError( - f"Accumulated tool request arguments is not valid JSON: {arguments_content_str}" - ) from e - return ToolRequestContent( - author="agent", - tool_call_id=tool_request_deltas[0].tool_call_id, - name=tool_request_deltas[0].name, - arguments=arguments, - ) - elif self._delta_type == "tool_response": - # Type assertion: we know all deltas are ToolResponseDelta when _delta_type is TOOL_RESPONSE - tool_response_deltas = [delta for delta in self._accumulated_deltas if isinstance(delta, ToolResponseDelta)] - tool_response_content_str = "".join([delta.content_delta or "" for delta in tool_response_deltas]) - return ToolResponseContent( - author="agent", - tool_call_id=tool_response_deltas[0].tool_call_id, - name=tool_response_deltas[0].name, - content=tool_response_content_str, - ) - elif self._delta_type == "reasoning": - # Convert accumulated reasoning deltas to ReasoningContent - # Sort by index to maintain order - summary_list = [ - self._reasoning_summaries[i] - for i in sorted(self._reasoning_summaries.keys()) - if self._reasoning_summaries[i] - ] - content_list = [ - self._reasoning_contents[i] - for i in sorted(self._reasoning_contents.keys()) - if self._reasoning_contents[i] - ] - - # Only return reasoning content if we have non-empty summaries or content - if summary_list or content_list: - return ReasoningContent( - author="agent", - summary=summary_list, - content=content_list if content_list else None, - type="reasoning", - style="static", - ) - else: - # Return empty text content instead of empty reasoning - return TextContent( - author="agent", - content="", - ) - else: - raise ValueError(f"Unknown delta type: {self._delta_type}") - - -class StreamingTaskMessageContext: - def __init__( - self, - task_id: str, - initial_content: TaskMessageContent, - agentex_client: AsyncAgentex, - streaming_service: "StreamingService", - streaming_mode: StreamingMode = "coalesced", - created_at: datetime | None = None, - ): - self.task_id = task_id - self.initial_content = initial_content - self.task_message: TaskMessage | None = None - self._agentex_client = agentex_client - self._streaming_service = streaming_service - self._is_closed = False - self._delta_accumulator = DeltaAccumulator() - self._streaming_mode: StreamingMode = streaming_mode - self._buffer: CoalescingBuffer | None = None - self._created_at = created_at - - async def __aenter__(self) -> "StreamingTaskMessageContext": - return await self.open() - - async def __aexit__(self, exc_type, exc_val, exc_tb): - return await self.close() - - async def open(self) -> "StreamingTaskMessageContext": - self._is_closed = False - - self.task_message = await self._agentex_client.messages.create( - task_id=self.task_id, - content=self.initial_content.model_dump(), - streaming_status="IN_PROGRESS", - created_at=self._created_at if self._created_at is not None else omit, - ) - - # Send the START event - start_event = StreamTaskMessageStart( - parent_task_message=self.task_message, - content=self.initial_content, - type="start", - ) - await self._streaming_service.stream_update(start_event) - - if self._streaming_mode == "coalesced": - self._buffer = CoalescingBuffer(on_flush=self._streaming_service.stream_update) - self._buffer.start() - - return self - - async def _reap_buffer(self) -> None: - """Drain and stop the coalescing buffer, releasing its background ticker. - - Idempotent: a no-op once the buffer has already been reaped. - """ - if self._buffer is not None: - await self._buffer.close() - self._buffer = None - - async def close(self) -> TaskMessage: - """Close the streaming context.""" - if not self.task_message: - raise ValueError("Context not properly initialized - no task message") - - # Reap the buffer (stopping its ticker) before the _is_closed - # short-circuit, so a context already marked done by a Full update can't - # leave the ticker orphaned. Draining here also lets consumers see the - # full delta sequence in order before DONE. - await self._reap_buffer() - - if self._is_closed: - return self.task_message # Already done (buffer reaped above) - - # Send the DONE event - done_event = StreamTaskMessageDone( - parent_task_message=self.task_message, - type="done", - ) - await self._streaming_service.stream_update(done_event) - - # Update the task message with the final content - has_deltas = ( - self._delta_accumulator._accumulated_deltas - or self._delta_accumulator._reasoning_summaries - or self._delta_accumulator._reasoning_contents - ) - if has_deltas: - self.task_message.content = self._delta_accumulator.convert_to_content() - - await self._agentex_client.messages.update( - task_id=self.task_id, - message_id=self.task_message.id, - content=self.task_message.content.model_dump(), - streaming_status="DONE", - ) - - # Mark the context as done - self._is_closed = True - return self.task_message - - async def stream_update(self, update: TaskMessageUpdate) -> TaskMessageUpdate | None: - """Stream an update to the repository. - - Behavior depends on the context's ``streaming_mode``: - - "off": delta updates feed the accumulator (so the persisted message - body is correct) but are never published. - - "per_token": delta updates are published immediately. - - "coalesced": delta updates are queued in a 50ms / 128-char window and - flushed as merged batches on a background ticker; the first delta - flushes immediately for fast perceived responsiveness. - - ``StreamTaskMessageDone`` and ``StreamTaskMessageFull`` updates always - publish synchronously regardless of mode so consumers and persistence - stay in sync. - """ - if self._is_closed: - raise ValueError("Context is already done") - - if not self.task_message: - raise ValueError("Context not properly initialized - no task message") - - if isinstance(update, StreamTaskMessageDelta): - if update.delta is not None: - self._delta_accumulator.add_delta(update.delta) - if self._streaming_mode == "off": - return update - if self._streaming_mode == "coalesced" and self._buffer is not None: - await self._buffer.add(update) - return update - - # A Full ends the stream and supersedes buffered deltas. Drain and stop - # the buffer BEFORE publishing the Full, so leftover deltas land in order - # (deltas -> Full) instead of trailing the terminal Full as a stale - # duplicate tail. This also stops the ticker, which would otherwise be - # orphaned when __aexit__'s close() short-circuits on _is_closed. - if isinstance(update, StreamTaskMessageFull): - await self._reap_buffer() - - result = await self._streaming_service.stream_update(update) - - if isinstance(update, StreamTaskMessageDone): - await self.close() - return update - elif isinstance(update, StreamTaskMessageFull): - await self._agentex_client.messages.update( - task_id=self.task_id, - message_id=update.parent_task_message.id, # type: ignore[union-attr] - content=update.content.model_dump(), - streaming_status="DONE", - ) - self._is_closed = True - return result - - -class StreamingService: - def __init__( - self, - agentex_client: AsyncAgentex, - stream_repository: StreamRepository, - ): - self._agentex_client = agentex_client - self._stream_repository = stream_repository - - def streaming_task_message_context( - self, - task_id: str, - initial_content: TaskMessageContent, - streaming_mode: StreamingMode = "coalesced", - created_at: datetime | None = None, - ) -> StreamingTaskMessageContext: - return StreamingTaskMessageContext( - task_id=task_id, - initial_content=initial_content, - agentex_client=self._agentex_client, - streaming_service=self, - streaming_mode=streaming_mode, - created_at=created_at, - ) - - async def stream_update(self, update: TaskMessageUpdate) -> TaskMessageUpdate | None: - """ - Stream an update to the repository. - - Args: - update: The update to stream - - Returns: - True if event was streamed successfully, False otherwise - """ - stream_topic = _get_stream_topic(update.parent_task_message.task_id) # type: ignore[union-attr] - - try: - await self._stream_repository.send_event( - topic=stream_topic, - event=update.model_dump(mode="json"), # type: ignore - ) - return update - except Exception as e: - logger.exception(f"Failed to stream event: {e}") - return None diff --git a/src/agentex/lib/core/services/adk/tasks.py b/src/agentex/lib/core/services/adk/tasks.py deleted file mode 100644 index f1dba08bd..000000000 --- a/src/agentex/lib/core/services/adk/tasks.py +++ /dev/null @@ -1,242 +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 interrupt_task( - self, - task_id: str, - reason: str | None = None, - trace_id: str | None = None, - parent_span_id: str | None = None, - ) -> Task: - trace = self._tracer.trace(trace_id) - async with trace.span( - parent_id=parent_span_id, - name="interrupt_task", - input={"task_id": task_id, "reason": reason}, - ) as span: - heartbeat_if_in_workflow("interrupt task") - task_model = await self._agentex_client.tasks.interrupt(task_id=task_id, reason=reason) - if span: - span.output = task_model.model_dump() - return task_model - - async def complete_task( - self, - task_id: str, - reason: str | None = None, - trace_id: str | None = None, - parent_span_id: str | None = None, - ) -> Task: - trace = self._tracer.trace(trace_id) - async with trace.span( - parent_id=parent_span_id, - name="complete_task", - input={"task_id": task_id, "reason": reason}, - ) as span: - heartbeat_if_in_workflow("complete task") - task_model = await self._agentex_client.tasks.complete(task_id=task_id, reason=reason) - if span: - span.output = task_model.model_dump() - return task_model - - async def fail_task( - self, - task_id: str, - reason: str | None = None, - trace_id: str | None = None, - parent_span_id: str | None = None, - ) -> Task: - trace = self._tracer.trace(trace_id) - async with trace.span( - parent_id=parent_span_id, - name="fail_task", - input={"task_id": task_id, "reason": reason}, - ) as span: - heartbeat_if_in_workflow("fail task") - task_model = await self._agentex_client.tasks.fail(task_id=task_id, reason=reason) - if span: - span.output = task_model.model_dump() - return task_model - - async def terminate_task( - self, - task_id: str, - reason: str | None = None, - trace_id: str | None = None, - parent_span_id: str | None = None, - ) -> Task: - trace = self._tracer.trace(trace_id) - async with trace.span( - parent_id=parent_span_id, - name="terminate_task", - input={"task_id": task_id, "reason": reason}, - ) as span: - heartbeat_if_in_workflow("terminate task") - task_model = await self._agentex_client.tasks.terminate(task_id=task_id, reason=reason) - if span: - span.output = task_model.model_dump() - return task_model - - async def timeout_task( - self, - task_id: str, - reason: str | None = None, - trace_id: str | None = None, - parent_span_id: str | None = None, - ) -> Task: - trace = self._tracer.trace(trace_id) - async with trace.span( - parent_id=parent_span_id, - name="timeout_task", - input={"task_id": task_id, "reason": reason}, - ) as span: - heartbeat_if_in_workflow("timeout task") - task_model = await self._agentex_client.tasks.timeout(task_id=task_id, reason=reason) - if span: - span.output = task_model.model_dump() - return task_model - - async def update_task( - self, - task_id: str | None = None, - task_name: str | None = None, - task_metadata: dict[str, object] | None = None, - trace_id: str | None = None, - parent_span_id: str | None = None, - ) -> Task: - trace = self._tracer.trace(trace_id) - async with trace.span( - parent_id=parent_span_id, - name="update_task", - input={"task_id": task_id, "task_name": task_name, "task_metadata": task_metadata}, - ) as span: - heartbeat_if_in_workflow("update task") - if not task_id and not task_name: - raise ValueError("Either task_id or task_name must be provided.") - if task_id: - task_model = await self._agentex_client.tasks.update_by_id(task_id=task_id, task_metadata=task_metadata) - elif task_name: - task_model = await self._agentex_client.tasks.update_by_name( - task_name=task_name, task_metadata=task_metadata - ) - else: - raise ValueError("Either task_id or task_name must be provided.") - if span: - span.output = task_model.model_dump() - return task_model - - async def query_workflow( - self, - task_id: str, - query_name: str, - trace_id: str | None = None, - parent_span_id: str | None = None, - ) -> TaskQueryWorkflowResponse: - trace = self._tracer.trace(trace_id) - async with trace.span( - parent_id=parent_span_id, - name="query_workflow", - input={"task_id": task_id, "query_name": query_name}, - ) as span: - heartbeat_if_in_workflow("query workflow") - result = await self._agentex_client.tasks.query_workflow(query_name=query_name, task_id=task_id) - if span: - span.output = result - return result diff --git a/src/agentex/lib/core/services/adk/tracing.py b/src/agentex/lib/core/services/adk/tracing.py 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 93e6b69e6..000000000 --- a/src/agentex/lib/core/temporal/activities/__init__.py +++ /dev/null @@ -1,219 +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.interrupt_task, - tasks_activities.complete_task, - tasks_activities.fail_task, - tasks_activities.terminate_task, - tasks_activities.timeout_task, - tasks_activities.update_task, - tasks_activities.query_workflow, - ## Tracing activities - tracing_activities.start_span, - tracing_activities.end_span, - # ACP activities - acp_activities.task_create, - acp_activities.message_send, - acp_activities.event_send, - acp_activities.task_cancel, - # Providers - ## LiteLLM activities - litellm_activities.chat_completion, - litellm_activities.chat_completion_auto_send, - litellm_activities.chat_completion_stream_auto_send, - ## OpenAI activities - openai_activities.run_agent, - openai_activities.run_agent_auto_send, - openai_activities.run_agent_streamed_auto_send, - # Utils - templating_activities.render_jinja, - ] - - # SGP activities - if sgp_client is not None: - sgp_all_activities = [ - sgp_activities.download_file_content, # type: ignore[union-attr] - ] - activities.extend(sgp_all_activities) - - return activities diff --git a/src/agentex/lib/core/temporal/activities/activity_helpers.py b/src/agentex/lib/core/temporal/activities/activity_helpers.py 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 bd1c430c4..000000000 --- a/src/agentex/lib/core/temporal/activities/adk/tasks_activities.py +++ /dev/null @@ -1,149 +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" - INTERRUPT_TASK = "interrupt-task" - COMPLETE_TASK = "complete-task" - FAIL_TASK = "fail-task" - TERMINATE_TASK = "terminate-task" - TIMEOUT_TASK = "timeout-task" - UPDATE_TASK = "update-task" - QUERY_WORKFLOW = "query-workflow" - - -class GetTaskParams(BaseModelWithTraceParams): - task_id: str | None = None - task_name: str | None = None - - -class DeleteTaskParams(BaseModelWithTraceParams): - task_id: str | None = None - task_name: str | None = None - - -class TaskStatusTransitionParams(BaseModelWithTraceParams): - task_id: str - reason: str | None = None - - -class UpdateTaskParams(BaseModelWithTraceParams): - task_id: str | None = None - task_name: str | None = None - task_metadata: dict[str, object] | None = None - - -class QueryWorkflowParams(BaseModelWithTraceParams): - task_id: str - query_name: str - - -class TasksActivities: - def __init__(self, tasks_service: TasksService): - self._tasks_service = tasks_service - - @activity.defn(name=TasksActivityName.GET_TASK) - async def get_task(self, params: GetTaskParams) -> TaskRetrieveResponse | TaskRetrieveByNameResponse: - return await self._tasks_service.get_task( - task_id=params.task_id, - task_name=params.task_name, - trace_id=params.trace_id, - parent_span_id=params.parent_span_id, - ) - - @activity.defn(name=TasksActivityName.DELETE_TASK) - async def delete_task(self, params: DeleteTaskParams) -> Task: - return await self._tasks_service.delete_task( # type: ignore[return-value] - task_id=params.task_id, - task_name=params.task_name, - trace_id=params.trace_id, - parent_span_id=params.parent_span_id, - ) - - @activity.defn(name=TasksActivityName.CANCEL_TASK) - async def cancel_task(self, params: TaskStatusTransitionParams) -> Task: - return await self._tasks_service.cancel_task( - task_id=params.task_id, - reason=params.reason, - trace_id=params.trace_id, - parent_span_id=params.parent_span_id, - ) - - @activity.defn(name=TasksActivityName.INTERRUPT_TASK) - async def interrupt_task(self, params: TaskStatusTransitionParams) -> Task: - return await self._tasks_service.interrupt_task( - task_id=params.task_id, - reason=params.reason, - trace_id=params.trace_id, - parent_span_id=params.parent_span_id, - ) - - @activity.defn(name=TasksActivityName.COMPLETE_TASK) - async def complete_task(self, params: TaskStatusTransitionParams) -> Task: - return await self._tasks_service.complete_task( - task_id=params.task_id, - reason=params.reason, - trace_id=params.trace_id, - parent_span_id=params.parent_span_id, - ) - - @activity.defn(name=TasksActivityName.FAIL_TASK) - async def fail_task(self, params: TaskStatusTransitionParams) -> Task: - return await self._tasks_service.fail_task( - task_id=params.task_id, - reason=params.reason, - trace_id=params.trace_id, - parent_span_id=params.parent_span_id, - ) - - @activity.defn(name=TasksActivityName.TERMINATE_TASK) - async def terminate_task(self, params: TaskStatusTransitionParams) -> Task: - return await self._tasks_service.terminate_task( - task_id=params.task_id, - reason=params.reason, - trace_id=params.trace_id, - parent_span_id=params.parent_span_id, - ) - - @activity.defn(name=TasksActivityName.TIMEOUT_TASK) - async def timeout_task(self, params: TaskStatusTransitionParams) -> Task: - return await self._tasks_service.timeout_task( - task_id=params.task_id, - reason=params.reason, - trace_id=params.trace_id, - parent_span_id=params.parent_span_id, - ) - - @activity.defn(name=TasksActivityName.UPDATE_TASK) - async def update_task(self, params: UpdateTaskParams) -> Task: - return await self._tasks_service.update_task( - task_id=params.task_id, - task_name=params.task_name, - task_metadata=params.task_metadata, - trace_id=params.trace_id, - parent_span_id=params.parent_span_id, - ) - - @activity.defn(name=TasksActivityName.QUERY_WORKFLOW) - async def query_workflow(self, params: QueryWorkflowParams) -> dict[str, object]: - return await self._tasks_service.query_workflow( - task_id=params.task_id, - query_name=params.query_name, - trace_id=params.trace_id, - parent_span_id=params.parent_span_id, - ) diff --git a/src/agentex/lib/core/temporal/activities/adk/tracing_activities.py b/src/agentex/lib/core/temporal/activities/adk/tracing_activities.py 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 4da2e9ca0..000000000 --- a/src/agentex/lib/core/temporal/plugins/__init__.py +++ /dev/null @@ -1,55 +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, - ... ContextInterceptor, - ... ) - >>> from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters - >>> from datetime import timedelta - >>> - >>> # Create streaming model provider - >>> model_provider = TemporalStreamingModelProvider() - >>> - >>> # Create STANDARD plugin with streaming model provider - >>> plugin = OpenAIAgentsPlugin( - ... model_params=ModelActivityParameters( - ... start_to_close_timeout=timedelta(seconds=120), - ... ), - ... model_provider=model_provider, - ... ) - >>> - >>> # Register interceptor with worker - >>> interceptor = ContextInterceptor() - >>> # Add interceptor to worker configuration -""" - -from agentex.lib.core.temporal.plugins.openai_agents import ( - ContextInterceptor, - TemporalStreamingHooks, - TemporalStreamingModel, - TemporalStreamingModelProvider, - streaming_task_id, - streaming_trace_id, - stream_lifecycle_content, - streaming_parent_span_id, -) - -__all__ = [ - "TemporalStreamingModel", - "TemporalStreamingModelProvider", - "ContextInterceptor", - "streaming_task_id", - "streaming_trace_id", - "streaming_parent_span_id", - "TemporalStreamingHooks", - "stream_lifecycle_content", -] diff --git a/src/agentex/lib/core/temporal/plugins/claude_agents/__init__.py b/src/agentex/lib/core/temporal/plugins/claude_agents/__init__.py deleted file mode 100644 index 1e9ee694a..000000000 --- a/src/agentex/lib/core/temporal/plugins/claude_agents/__init__.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Claude Agents SDK integration with Temporal. - -.. deprecated:: - This is the original Claude Code integration: it drives the Python - ``claude-agent-sdk`` directly and hand-rolls its own streaming + tracing - (and does not derive reasoning spans). It is superseded by the unified - harness tap (``agentex.lib.adk.ClaudeCodeTurn`` over the ``claude -p - --output-format stream-json`` CLI stdout, delivered via ``UnifiedEmitter``), - which routes Claude Code through the same canonical ``StreamTaskMessage*`` - stream as every other harness. It still works, but new agents should use the - tap and existing ones should plan to migrate; see - ``adk/docs/migration-0.16.0.md`` for the before/after. - -This plugin provides integration between Claude Agents SDK and AgentEx's -Temporal-based orchestration platform. - -Features: -- Temporal activity wrapper for Claude SDK calls -- Real-time streaming to Redis/UI -- Session resume for conversation context -- Tool call visibility (Read, Write, Bash, etc.) -- Subagent support with nested tracing -- Workspace isolation per task - -Architecture: -- activities.py: Temporal activity definitions -- message_handler.py: Message parsing and streaming logic -- Reuses OpenAI's ContextInterceptor for context threading - -Usage: - from agentex.lib.core.temporal.plugins.claude_agents import ( - run_claude_agent_activity, - create_workspace_directory, - ContextInterceptor, - ) - - # In worker - worker = AgentexWorker( - task_queue=queue_name, - interceptors=[ContextInterceptor()], - ) - - activities = get_all_activities() - activities.extend([run_claude_agent_activity, create_workspace_directory]) - - await worker.run(activities=activities, workflow=YourWorkflow) -""" - -from agentex.lib.core.temporal.plugins.claude_agents.hooks import ( - TemporalStreamingHooks, - create_streaming_hooks, -) -from agentex.lib.core.temporal.plugins.claude_agents.activities import ( - claude_options_to_dict, - run_claude_agent_activity, - create_workspace_directory, -) -from agentex.lib.core.temporal.plugins.claude_agents.message_handler import ( - ClaudeMessageHandler, -) - -# Reuse OpenAI's context threading - this is the key to streaming! -from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ( - ContextInterceptor, - streaming_task_id, - streaming_trace_id, - streaming_parent_span_id, -) - -__all__ = [ - # Activities - "run_claude_agent_activity", - "create_workspace_directory", - "claude_options_to_dict", - # Message handling - "ClaudeMessageHandler", - # Hooks - "create_streaming_hooks", - "TemporalStreamingHooks", - # Context threading (reused from OpenAI) - "ContextInterceptor", - "streaming_task_id", - "streaming_trace_id", - "streaming_parent_span_id", -] diff --git a/src/agentex/lib/core/temporal/plugins/claude_agents/activities.py b/src/agentex/lib/core/temporal/plugins/claude_agents/activities.py 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 453e27074..000000000 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/__init__.py +++ /dev/null @@ -1,86 +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.run import ( - OpenAIAgentsTurnResult, - run_turn, -) -from agentex.lib.core.temporal.plugins.openai_agents.hooks.hooks import ( - TemporalStreamingHooks, -) -from agentex.lib.core.temporal.plugins.openai_agents.hooks.activities import ( - stream_lifecycle_content, -) -from agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model import ( - TemporalStreamingModel, - TemporalStreamingModelProvider, -) -from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ( - ContextInterceptor, - streaming_task_id, - streaming_trace_id, - streaming_parent_span_id, -) - -__all__ = [ - "TemporalStreamingModel", - "TemporalStreamingModelProvider", - "ContextInterceptor", - "streaming_task_id", - "streaming_trace_id", - "streaming_parent_span_id", - "TemporalStreamingHooks", - "stream_lifecycle_content", - "run_turn", - "OpenAIAgentsTurnResult", -] diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/__init__.py b/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/__init__.py 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 30d358cc9..000000000 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/hooks.py +++ /dev/null @@ -1,395 +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, and (optionally) tracing tool calls -to SGP with both inputs and outputs. - -Two responsibilities, independently switchable: - -1. UI message emission, split into tool requests / tool responses / handoffs - (each default True). Leave all on for the non-streaming model provider, which - does not emit them itself. When pairing with ``TemporalStreamingModelProvider`` - set ``emit_tool_requests=False`` โ€” that model already streams the tool REQUEST - from the model output, so emitting it here double-posts. But keep - ``emit_tool_responses=True``: the streaming model does NOT emit a function - tool's response, so ``on_tool_end`` is its only source (disabling it makes the - tool-call "done" events vanish). ``run_turn`` wires this correctly for you. - -2. SGP tracing (enabled when ``trace_id`` is provided): opens a span named after - the tool on tool start with the tool ARGUMENTS as its input and closes it on - tool end with the result as its output, parented to ``parent_span_id``. Token - usage metrics are always emitted via ``LLMMetricsHooks`` regardless of these - flags. -""" - -from __future__ import annotations - -import json -import logging -from typing import Any, override -from datetime import timedelta - -from agents import Tool, Agent, RunContextWrapper -from temporalio import workflow -from agents.tool_context import ToolContext - -from agentex.types.text_content import TextContent -from agentex.types.task_message_content import ToolRequestContent, ToolResponseContent -from agentex.lib.core.observability.llm_metrics_hooks import LLMMetricsHooks -from agentex.lib.core.temporal.plugins.openai_agents.hooks.activities import stream_lifecycle_content - -logger = logging.getLogger(__name__) - -# Best-effort tracing budget โ€” a tracing outage must never break a tool call. -_TRACE_TIMEOUT = timedelta(seconds=5) -# Cap tool-result span output so a large payload can't bloat the trace. -_MAX_SPAN_OUTPUT_CHARS = 2000 - - -def _get_adk() -> Any: - """Lazily import the adk facade for workflow-safe tracing. - - Kept lazy (not a module-level import) so this core hooks module does not pull - the full adk surface โ€” and its optional deps โ€” at import time. Only invoked - when a tool span is actually created (i.e. when ``trace_id`` is set). - """ - from agentex.lib import adk - - return adk - - -class TemporalStreamingHooks(LLMMetricsHooks): - """Convenience hooks class for streaming OpenAI Agent lifecycle events to the AgentEx UI. - - This class automatically streams agent lifecycle events (tool calls, handoffs) to the - AgentEx UI via Temporal activities. It subclasses the OpenAI Agents SDK's RunHooks - to intercept lifecycle events and forward them for real-time UI updates. - - Lifecycle events streamed (each gated by its own flag, all default True): - - Tool requests (on_tool_start, ``emit_tool_requests``): when a tool is invoked - - Tool responses (on_tool_end, ``emit_tool_responses``): the tool's result - - Agent handoffs (on_handoff, ``emit_handoffs``): when control transfers - - Tracing (when ``trace_id`` is provided): - - One SGP span per tool call, named after the tool, with the tool - arguments as the span input and the tool result as the span output. - - Usage: - Basic usage - streams all lifecycle events:: - - from agentex.lib.core.temporal.plugins.openai_agents import TemporalStreamingHooks - - hooks = TemporalStreamingHooks(task_id="abc123") - result = await Runner.run(agent, input, hooks=hooks) - - Paired with the streaming model provider (it already streams the tool - REQUEST, so suppress that here โ€” but keep responses, which the model does - not emit). Prefer ``run_turn`` which wires this for you:: - - hooks = TemporalStreamingHooks( - task_id="abc123", - emit_tool_requests=False, - emit_tool_responses=True, - trace_id=trace_id, - parent_span_id=parent_span_id, - ) - - Advanced - subclass for custom behavior:: - - class MyCustomHooks(TemporalStreamingHooks): - async def on_tool_start(self, context, agent, tool): - # Add custom logic before streaming - await self.my_custom_logging(tool) - # Call parent to stream to UI - await super().on_tool_start(context, agent, tool) - - async def on_agent_start(self, context, agent): - # Override empty methods for additional tracking - print(f"Agent {agent.name} started") - - Power users can ignore this class and subclass agents.RunHooks directly for full control. - - Note: - Tool arguments are extracted from the ToolContext's tool_arguments field, - which contains a JSON string of the arguments passed to the tool. - - Attributes: - task_id: The AgentEx task ID for routing streamed events - timeout: Timeout for streaming activity calls (default: 10 seconds) - emit_tool_requests: Whether to stream the ToolRequestContent on tool start - emit_tool_responses: Whether to stream the ToolResponseContent on tool end - emit_handoffs: Whether to stream the handoff text message - trace_id: When set, tool calls are traced to SGP (input + output) - parent_span_id: Parent span for the per-tool spans - """ - - def __init__( - self, - task_id: str, - timeout: timedelta = timedelta(seconds=10), - *, - emit_tool_requests: bool = True, - emit_tool_responses: bool = True, - emit_handoffs: bool = True, - trace_id: str | None = None, - parent_span_id: str | None = None, - ): - """Initialize the streaming hooks. - - Request and response emission are independently switchable because the - ``TemporalStreamingModelProvider`` emits a function tool's REQUEST from - the model output but NOT its response โ€” the function result only ever - surfaces here via ``on_tool_end``. So when pairing with that provider, - set ``emit_tool_requests=False`` (the model already posted the request) - but keep ``emit_tool_responses=True`` (otherwise the tool-call "done" - events disappear). ``run_turn`` wires this correctly for you. - - Args: - task_id: AgentEx task ID for routing streamed events to the correct UI session - timeout: Timeout for streaming activity invocations (default: 10 seconds) - emit_tool_requests: When True (default) stream a ToolRequestContent on - tool start. Set False when a streaming model provider already - emits the request, to avoid double-posting it. - emit_tool_responses: When True (default) stream a ToolResponseContent - on tool end. Keep True with the streaming model provider โ€” it does - NOT emit function-tool responses, so this is their only source. - emit_handoffs: When True (default) stream a handoff text message. - trace_id: When provided, open an SGP span per tool call (named after - the tool) with the arguments as input and the result as output. When None, - no tool spans are created (token-usage metrics still emit). - parent_span_id: Parent span id the per-tool spans attach to. - """ - super().__init__() - self.task_id = task_id - self.timeout = timeout - self.emit_tool_requests = emit_tool_requests - self.emit_tool_responses = emit_tool_responses - self.emit_handoffs = emit_handoffs - self.trace_id = trace_id - self.parent_span_id = parent_span_id - # tool_call_id -> open SGP span, so on_tool_end closes the right one. - self._tool_spans: dict[str, Any] = {} - - @staticmethod - def _tool_call_id(context: RunContextWrapper, tool: Tool) -> str: - tool_context = context if isinstance(context, ToolContext) else None - return getattr(tool_context, "tool_call_id", None) or f"call_{id(tool)}" - - @staticmethod - def _parse_tool_arguments(context: RunContextWrapper) -> dict[str, Any]: - """Parse the JSON ``tool_arguments`` off a ToolContext into a dict. - - Returns an empty dict for a non-ToolContext or unparseable arguments โ€” - a tool call must never fail because its args could not be displayed. - """ - tool_context = context if isinstance(context, ToolContext) else None - raw = getattr(tool_context, "tool_arguments", None) - if not raw: - return {} - try: - parsed = json.loads(raw) - except (json.JSONDecodeError, TypeError): - logger.warning(f"Failed to parse tool arguments: {raw!r}") - return {} - return parsed if isinstance(parsed, dict) else {"value": parsed} - - @override - async def on_agent_start(self, context: RunContextWrapper, agent: Agent) -> None: # noqa: ARG002 - """Called when an agent starts execution. - - Default implementation logs the event. Override to add custom behavior. - - Args: - context: The run context wrapper - agent: The agent that is starting - """ - logger.debug(f"[TemporalStreamingHooks] Agent '{agent.name}' started execution") - - @override - async def on_agent_end(self, context: RunContextWrapper, agent: Agent, output: Any) -> None: # noqa: ARG002 - """Called when an agent completes execution. - - Default implementation logs the event. Override to add custom behavior. - - Args: - context: The run context wrapper - agent: The agent that completed - output: The agent's output - """ - logger.debug( - f"[TemporalStreamingHooks] Agent '{agent.name}' completed execution with output type: {type(output).__name__}" - ) - - @override - async def on_tool_start(self, context: RunContextWrapper, agent: Agent, tool: Tool) -> None: # noqa: ARG002 - """Stream the tool request (optional) and open a traced span (optional). - - Streams a ToolRequestContent message when ``emit_tool_requests`` is True, - and opens an SGP span named after the tool (input = arguments) when - ``trace_id`` is set. Both read the same parsed arguments. - - Args: - context: The run context wrapper (a ToolContext with tool_call_id and tool_arguments) - agent: The agent executing the tool - tool: The tool being executed - """ - tool_call_id = self._tool_call_id(context, tool) - tool_arguments = self._parse_tool_arguments(context) - - if self.emit_tool_requests: - await workflow.execute_activity( - stream_lifecycle_content, - args=[ - self.task_id, - ToolRequestContent( - author="agent", - tool_call_id=tool_call_id, - name=tool.name, - arguments=tool_arguments, - ).model_dump(), - ], - start_to_close_timeout=self.timeout, - ) - - await self._maybe_start_tool_span(tool_call_id, tool.name, tool_arguments) - - @override - async def on_tool_end( - self, - context: RunContextWrapper, - agent: Agent, # noqa: ARG002 - tool: Tool, - result: str, - ) -> None: - """Stream the tool response (optional) and close the traced span (optional). - - Streams a ToolResponseContent message when ``emit_tool_responses`` is True, - and closes the matching tool span (output = result) when one was - opened in on_tool_start. - - Args: - context: The run context wrapper (a ToolContext with tool_call_id) - agent: The agent that executed the tool - tool: The tool that was executed - result: The tool's execution result - """ - tool_call_id = self._tool_call_id(context, tool) - - if self.emit_tool_responses: - await workflow.execute_activity( - stream_lifecycle_content, - args=[ - self.task_id, - ToolResponseContent( - author="agent", - tool_call_id=tool_call_id, - name=tool.name, - content=result, - ).model_dump(), - ], - start_to_close_timeout=self.timeout, - ) - - await self._maybe_end_tool_span(tool_call_id, result) - - @override - async def on_handoff( - self, - context: RunContextWrapper, - from_agent: Agent, - to_agent: Agent, # noqa: ARG002 - ) -> None: - """Stream handoff message when control transfers between agents. - - Sends a text message to the UI indicating that one agent is handing off - to another agent. No-op when ``emit_handoffs`` is False. - - Args: - context: The run context wrapper - from_agent: The agent transferring control - to_agent: The agent receiving control - """ - if not self.emit_handoffs: - return - await workflow.execute_activity( - stream_lifecycle_content, - args=[ - self.task_id, - TextContent( - author="agent", - content=f"Handoff from {from_agent.name} to {to_agent.name}", - type="text", - ).model_dump(), - ], - start_to_close_timeout=self.timeout, - ) - - async def _maybe_start_tool_span(self, tool_call_id: str, tool_name: str, arguments: dict[str, Any]) -> None: - """Open a span named after the tool with the arguments as input. - - The span name is the bare ``tool_name`` (no prefix) to match the shared - unified-harness span reducer (``core/harness/span_derivation.py``), so - OpenAI Temporal traces look the same as every other harness. - - Best-effort: tracing must never break a tool call, so any failure is - logged and swallowed. No-op when ``trace_id`` is not set. - """ - if not self.trace_id: - return - try: - span = await _get_adk().tracing.start_span( - trace_id=self.trace_id, - parent_id=self.parent_span_id, - name=tool_name, - input={"arguments": arguments}, - start_to_close_timeout=_TRACE_TIMEOUT, - ) - if span is not None: - self._tool_spans[tool_call_id] = span - except Exception as e: # noqa: BLE001 - tracing is best-effort - logger.warning(f"[tracing] tool start_span failed (non-fatal): {e}") - - async def _maybe_end_tool_span(self, tool_call_id: str, result: Any) -> None: - """Close the span opened for ``tool_call_id`` with the result as output.""" - span = self._tool_spans.pop(tool_call_id, None) - if span is None or not self.trace_id: - return - try: - span.output = {"result": str(result)[:_MAX_SPAN_OUTPUT_CHARS]} - await _get_adk().tracing.end_span( - trace_id=self.trace_id, - span=span, - start_to_close_timeout=_TRACE_TIMEOUT, - ) - except Exception as e: # noqa: BLE001 - tracing is best-effort - logger.warning(f"[tracing] tool end_span failed (non-fatal): {e}") - - async def close_open_tool_spans(self) -> None: - """Close any tool spans still open because ``on_tool_end`` never fired. - - ``on_tool_start`` opens a span that ``on_tool_end`` is expected to close. - If the runner terminates mid-tool (max-turns exceeded, cancellation, an - unexpected SDK exception), the matching ``on_tool_end`` never runs and the - span would otherwise stay open forever โ€” orphaned in the tracing backend. - Call this from a ``finally`` around ``Runner.run`` to drain the leftovers. - - Best-effort, like the rest of tracing: each span is closed with an - ``incomplete`` marker and any failure is logged and swallowed. - """ - if not self._tool_spans: - return - orphaned = list(self._tool_spans.items()) - self._tool_spans.clear() - for tool_call_id, span in orphaned: - logger.warning( - f"[tracing] tool span for {tool_call_id} left open (on_tool_end never fired); closing as incomplete" - ) - try: - span.output = {"result": None, "status": "incomplete"} - await _get_adk().tracing.end_span( - trace_id=self.trace_id, - span=span, - start_to_close_timeout=_TRACE_TIMEOUT, - ) - except Exception as e: # noqa: BLE001 - tracing is best-effort - logger.warning(f"[tracing] orphan tool end_span failed (non-fatal): {e}") diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/interceptors/__init__.py b/src/agentex/lib/core/temporal/plugins/openai_agents/interceptors/__init__.py 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 19dd967d5..000000000 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/models/__init__.py +++ /dev/null @@ -1,15 +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_streaming_model import ( - TemporalStreamingModel, - TemporalStreamingModelProvider, -) - -__all__ = [ - "TemporalStreamingModel", - "TemporalStreamingModelProvider", -] diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/models/temporal_streaming_model.py b/src/agentex/lib/core/temporal/plugins/openai_agents/models/temporal_streaming_model.py deleted file mode 100644 index 7c8690f21..000000000 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/models/temporal_streaming_model.py +++ /dev/null @@ -1,1352 +0,0 @@ -"""Custom Temporal Model Provider with streaming support for OpenAI agents.""" -from __future__ import annotations - -import json -import time -import uuid -from typing import Any, List, Union, Optional, override - -from agents import ( - Tool, - Model, - Handoff, - FunctionTool, - ModelTracing, - ModelProvider, - ModelResponse, - ModelSettings, - TResponseInputItem, - AgentOutputSchemaBase, -) -from openai import NOT_GIVEN, AsyncOpenAI -from agents.tool import ( - ComputerTool, - HostedMCPTool, - WebSearchTool, - FileSearchTool, - LocalShellTool, - CodeInterpreterTool, - ImageGenerationTool, -) -from agents.computer import Computer, AsyncComputer - -# Re-export the canonical StreamingMode literal from the streaming service so -# all layers share a single definition. -from agentex.lib.core.services.adk.streaming import StreamingMode as StreamingMode -from agentex.lib.core.observability.llm_metrics import get_llm_metrics -from agentex.lib.core.observability.llm_metrics_hooks import record_llm_failure - -try: - from agents.tool import ShellTool # type: ignore[attr-defined] -except ImportError: - ShellTool = None # type: ignore[assignment,misc] -from agents.usage import Usage, InputTokensDetails, OutputTokensDetails # type: ignore[attr-defined] -from agents.model_settings import MCPToolChoice -from openai.types.responses import ( - ResponseOutputText, - ResponseOutputMessage, - ResponseCompletedEvent, - ResponseTextDeltaEvent, - ResponseFunctionToolCall, - ResponseOutputItemDoneEvent, - # Event types for proper type checking - ResponseOutputItemAddedEvent, - ResponseReasoningTextDeltaEvent, - ResponseReasoningSummaryPartDoneEvent, - ResponseFunctionCallArgumentsDoneEvent, - ResponseReasoningSummaryPartAddedEvent, - ResponseReasoningSummaryTextDeltaEvent, - ResponseFunctionCallArgumentsDeltaEvent, -) -from openai.types.responses.response_prompt_param import ResponsePromptParam - -# AgentEx SDK imports -from agentex.lib import adk -from agentex.lib.utils.logging import make_logger -from agentex.lib.core.tracing.tracer import AsyncTracer -from agentex.types.task_message_delta import TextDelta, ToolRequestDelta, ReasoningContentDelta, ReasoningSummaryDelta -from agentex.types.task_message_update import StreamTaskMessageFull, StreamTaskMessageDelta -from agentex.types.task_message_content import TextContent, ReasoningContent, ToolRequestContent, ToolResponseContent -from agentex.lib.adk.utils._modules.client import create_async_agentex_client -from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ( - streaming_task_id, - streaming_trace_id, - streaming_parent_span_id, -) - -# Use the SDK's make_logger so this module's INFO/DEBUG output is actually -# visible (raw ``logging.getLogger`` returns a logger with no handler/level -# configured, which silently drops anything below WARNING). Keep the explicit -# name "agentex.temporal.streaming" so any external logging config targeting -# that name keeps working. -logger = make_logger("agentex.temporal.streaming") - - -# LLM metrics live in agentex.lib.core.observability.llm_metrics so other -# code paths (sync ACP, Claude SDK plugin, future provider integrations) -# can share the same instrument definitions without redefining names. - - -def _serialize_item(item: Any) -> dict[str, Any]: - """ - Universal serializer for any item type from OpenAI Agents SDK. - - Uses model_dump() for Pydantic models, otherwise extracts attributes manually. - Filters out internal Pydantic fields that can't be serialized. - """ - if hasattr(item, 'model_dump'): - # Pydantic model - use model_dump for proper serialization - try: - return item.model_dump(mode='json', exclude_unset=True) - except Exception: - # Fallback to dict conversion - return dict(item) if hasattr(item, '__iter__') else {} - else: - # Not a Pydantic model - extract attributes manually - item_dict = {} - for attr_name in dir(item): - if not attr_name.startswith('_') and attr_name not in ('model_fields', 'model_config', 'model_computed_fields'): - try: - attr_value = getattr(item, attr_name, None) - # Skip methods and None values - if attr_value is not None and not callable(attr_value): - # Convert to JSON-serializable format - if hasattr(attr_value, 'model_dump'): - item_dict[attr_name] = attr_value.model_dump() - elif isinstance(attr_value, (str, int, float, bool, list, dict)): - item_dict[attr_name] = attr_value - else: - item_dict[attr_name] = str(attr_value) - except Exception: - # Skip attributes that can't be accessed - pass - return item_dict - - -# Responses-API output items for server-side / hosted tools. These execute inside -# the Responses API, so they never become function_call items AND the SDK's -# RunHooks (on_tool_start/on_tool_end) never fire for them. The streaming loop -# must surface them explicitly, as a tool request + response pair, when the item -# completes (by then it carries the full query/result). -_HOSTED_TOOL_TYPES = frozenset( - { - "web_search_call", - "file_search_call", - "code_interpreter_call", - "image_generation_call", - "mcp_call", - "computer_call", - "local_shell_call", - } -) - -# Cap on the rendered hosted-tool result string (UI / trace readability). -_HOSTED_TOOL_RESULT_CAP = 2000 - - -def _coerce_args(raw: Any) -> dict[str, Any]: - """Best-effort coerce a hosted-tool's arguments to a dict for the UI.""" - if raw is None: - return {} - if isinstance(raw, dict): - return raw - if isinstance(raw, str): - try: - parsed = json.loads(raw) - return parsed if isinstance(parsed, dict) else {"value": parsed} - except (json.JSONDecodeError, ValueError): - return {"raw": raw} - serialized = _serialize_item(raw) - return serialized if isinstance(serialized, dict) else {"value": str(raw)} - - -def _hosted_tool_request(item: Any) -> tuple[str, str, dict[str, Any]]: - """Extract (call_id, display_name, arguments) from a hosted-tool item.""" - itype = getattr(item, "type", "") or "" - call_id = ( - getattr(item, "id", "") - or getattr(item, "call_id", "") - or f"hosted_{uuid.uuid4().hex[:8]}" - ) - name = itype[:-5] if itype.endswith("_call") else itype # web_search_call -> web_search - args: dict[str, Any] = {} - if itype == "web_search_call": - action = getattr(item, "action", None) - if action is not None: - args = _coerce_args(action) - elif itype == "file_search_call": - args = {"queries": list(getattr(item, "queries", []) or [])} - elif itype == "code_interpreter_call": - args = {"code": getattr(item, "code", "") or ""} - elif itype in ("computer_call", "local_shell_call"): - # Both carry an `action` object: a ComputerAction (click/scroll/type/...) - # or a LocalShellCallAction (command/env/cwd). Surface it as the args so - # the trace shows what the tool actually did, not just its status. - action = getattr(item, "action", None) - if action is not None: - args = _coerce_args(action) - elif itype == "mcp_call": - mcp_name = getattr(item, "name", None) or "mcp" - server = getattr(item, "server_label", None) - name = f"{server}.{mcp_name}" if server else mcp_name - args = _coerce_args(getattr(item, "arguments", None)) - return call_id, name, args - - -def _hosted_tool_result(item: Any) -> str: - """Extract a short result string from a completed hosted-tool item.""" - itype = getattr(item, "type", "") or "" - if itype == "mcp_call": - err = getattr(item, "error", None) - if err: - return f"error: {err}" - out = getattr(item, "output", None) - if out: - return str(out) - elif itype == "code_interpreter_call": - outputs = getattr(item, "outputs", None) - if outputs: - return json.dumps([_serialize_item(o) for o in outputs])[:_HOSTED_TOOL_RESULT_CAP] - elif itype == "file_search_call": - results = getattr(item, "results", None) - if results: - return json.dumps([_serialize_item(r) for r in results])[:_HOSTED_TOOL_RESULT_CAP] - elif itype == "image_generation_call": - # `result` is base64 image data; surface a compact reference instead of - # dumping the (large) payload into the trace. - result = getattr(item, "result", None) - if result: - return f"" - return str(getattr(item, "status", "completed") or "completed") - - -class TemporalStreamingModel(Model): - """Custom model implementation with streaming support.""" - - def __init__( - self, - model_name: str = "gpt-4o", - _use_responses_api: bool = True, - openai_client: Optional[AsyncOpenAI] = None, - streaming_mode: StreamingMode = "coalesced", - ): - """Initialize the streaming model with OpenAI client and model name. - - Args: - model_name: The name of the OpenAI model to use (default: "gpt-4o") - _use_responses_api: Internal flag for responses API (deprecated, always True) - openai_client: Optional custom AsyncOpenAI client. If not provided, a default - client with max_retries=0 will be created (since Temporal handles retries) - streaming_mode: How per-delta updates flow to consumers. Defaults to - "coalesced" (50ms / 128-char windowed batches with an - immediate first-delta flush) for low latency without - giving up streaming UX. Use "per_token" for legacy - publish-every-delta behavior, or "off" to suppress - per-delta publishes entirely. - """ - # Use provided client or create default (Temporal handles retries) - self.client = openai_client if openai_client is not None else AsyncOpenAI(max_retries=0) - self.model_name = model_name - # Always use Responses API for all models - self.use_responses_api = True - self.streaming_mode: StreamingMode = streaming_mode - - # Initialize tracer as a class variable - agentex_client = create_async_agentex_client() - self.tracer = AsyncTracer(agentex_client) - - logger.info(f"[TemporalStreamingModel] Initialized model={self.model_name}, use_responses_api={self.use_responses_api}, custom_client={openai_client is not None}, streaming_mode={self.streaming_mode}, tracer=initialized") - - def _non_null_or_not_given(self, value: Any) -> Any: - """Convert None to NOT_GIVEN sentinel, matching OpenAI SDK pattern.""" - return value if value is not None else NOT_GIVEN - - def _prepare_response_input(self, input: Union[str, list[TResponseInputItem]]) -> List[dict]: - """Convert input to Responses API format. - - Args: - input: Either a string prompt or list of ResponseInputItem messages - - Returns: - List of input items in Responses API format - """ - response_input = [] - - if isinstance(input, list): - # Process list of ResponseInputItem objects - for _idx, item in enumerate(input): - # Convert to dict if needed - if isinstance(item, dict): - item_dict = item - else: - item_dict = item.model_dump() if hasattr(item, 'model_dump') else item - - item_type = item_dict.get("type") - - if item_type == "message": - # ResponseOutputMessage format - role = item_dict.get("role", "assistant") - content_list = item_dict.get("content", []) - - # Build content array - content_array = [] - for content_item in content_list: - if isinstance(content_item, dict): - if content_item.get("type") == "output_text": - # For assistant messages, keep as output_text - # For user messages, convert to input_text - if role == "user": - content_array.append({ - "type": "input_text", - "text": content_item.get("text", "") - }) - else: - content_array.append({ - "type": "output_text", - "text": content_item.get("text", "") - }) - else: - content_array.append(content_item) - - response_input.append({ - "type": "message", - "role": role, - "content": content_array - }) - - elif item_type == "function_call": - # Function call from previous response - logger.debug(f"[Responses API] function_call item keys: {list(item_dict.keys())}") - call_id = item_dict.get("call_id") or item_dict.get("id") - if not call_id: - logger.debug(f"[Responses API] WARNING: No call_id found in function_call item!") - logger.debug(f"[Responses API] Full item: {item_dict}") - # Generate a fallback ID if missing - call_id = f"call_{uuid.uuid4().hex[:8]}" - logger.debug(f"[Responses API] Generated fallback call_id: {call_id}") - logger.debug(f"[Responses API] Adding function_call with call_id={call_id}, name={item_dict.get('name')}") - response_input.append({ - "type": "function_call", - "call_id": call_id, # API expects 'call_id' not 'id' - "name": item_dict.get("name", ""), - "arguments": item_dict.get("arguments", "{}"), - }) - - elif item_type == "function_call_output": - # Function output/response - call_id = item_dict.get("call_id") - if not call_id: - logger.debug(f"[Responses API] WARNING: No call_id in function_call_output!") - # Try to find it from id field - call_id = item_dict.get("id") - response_input.append({ - "type": "function_call_output", - "call_id": call_id or "", - "output": item_dict.get("output", "") - }) - - elif item_dict.get("role") == "user": - # Simple user message - response_input.append({ - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": item_dict.get("content", "")}] - }) - - elif item_dict.get("role") == "tool": - # Tool message - response_input.append({ - "type": "function_call_output", - "call_id": item_dict.get("tool_call_id"), - "output": item_dict.get("content") - }) - else: - logger.debug(f"[Responses API] Skipping unhandled item type: {item_type}, role: {item_dict.get('role')}") - - elif isinstance(input, str): - # Simple string input - response_input.append({ - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": input}] - }) - - return response_input - - def _convert_tools(self, tools: list[Tool], handoffs: list[Handoff]) -> tuple[List[dict], List[str]]: - """Convert tools and handoffs to Responses API format. - - Args: - tools: List of Tool objects - handoffs: List of Handoff objects - - Returns: - Tuple of (converted_tools, include_list) where include_list contains - additional response data to request - """ - response_tools = [] - tool_includes = [] - - # Check for multiple computer tools (only one allowed) - computer_tools = [tool for tool in tools if isinstance(tool, ComputerTool)] - if len(computer_tools) > 1: - raise ValueError(f"You can only provide one computer tool. Got {len(computer_tools)}") - - # Convert each tool based on its type - for tool in tools: - if isinstance(tool, FunctionTool): - response_tools.append({ - "type": "function", - "name": tool.name, - "description": tool.description or "", - "parameters": tool.params_json_schema if tool.params_json_schema else {}, - "strict": tool.strict_json_schema, - }) - - elif isinstance(tool, WebSearchTool): - tool_config = { - "type": "web_search", - } - # filters attribute was removed from WebSearchTool API - if hasattr(tool, 'user_location') and tool.user_location is not None: - tool_config["user_location"] = tool.user_location - if hasattr(tool, 'search_context_size') and tool.search_context_size is not None: - tool_config["search_context_size"] = tool.search_context_size - response_tools.append(tool_config) - - elif isinstance(tool, FileSearchTool): - tool_config = { - "type": "file_search", - "vector_store_ids": tool.vector_store_ids, - } - if tool.max_num_results: - tool_config["max_num_results"] = tool.max_num_results - if tool.ranking_options: - tool_config["ranking_options"] = tool.ranking_options - if tool.filters: - tool_config["filters"] = tool.filters - response_tools.append(tool_config) - - # Add include for file search results if needed - if tool.include_search_results: - tool_includes.append("file_search_call.results") - - elif isinstance(tool, ComputerTool): - # In newer openai-agents, tool.computer may be a factory - # (ComputerCreate/ComputerProvider). Only concrete Computer - # / AsyncComputer instances expose environment/dimensions. - computer = tool.computer - if not isinstance(computer, (Computer, AsyncComputer)): - raise ValueError( - "ComputerTool.computer must be a Computer or AsyncComputer " - "instance for Responses API serialization; got " - f"{type(computer).__name__}" - ) - environment = computer.environment - dimensions = computer.dimensions - if environment is None or dimensions is None: - raise ValueError( - "ComputerTool requires `environment` and `dimensions` on the " - "Computer/AsyncComputer implementation." - ) - response_tools.append({ - "type": "computer_use_preview", - "environment": environment, - "display_width": dimensions[0], - "display_height": dimensions[1], - }) - - elif isinstance(tool, HostedMCPTool): - response_tools.append(tool.tool_config) - - elif isinstance(tool, ImageGenerationTool): - response_tools.append(tool.tool_config) - - elif isinstance(tool, CodeInterpreterTool): - response_tools.append(tool.tool_config) - - elif isinstance(tool, LocalShellTool): - # LocalShellTool API changed - no longer has working_directory - # The executor handles execution details internally - response_tools.append({ - "type": "local_shell", - }) - - elif ShellTool is not None and isinstance(tool, ShellTool): - environment = dict(tool.environment) if tool.environment else {"type": "local"} - response_tools.append({ - "type": "shell", - "environment": environment, - }) - - else: - logger.warning(f"Unknown tool type: {type(tool).__name__}, skipping") - - # Convert handoffs (always function tools) - for handoff in handoffs: - response_tools.append({ - "type": "function", - "name": handoff.tool_name, - "description": handoff.tool_description or f"Transfer to {handoff.agent_name}", - "parameters": handoff.input_json_schema if handoff.input_json_schema else {}, - }) - - return response_tools, tool_includes - - def _build_reasoning_param(self, model_settings: ModelSettings) -> Any: - """Build reasoning parameter from model settings. - - Args: - model_settings: Model configuration settings - - Returns: - Reasoning parameter dict or NOT_GIVEN - """ - if not model_settings.reasoning: - return NOT_GIVEN - - if hasattr(model_settings.reasoning, 'effort') and model_settings.reasoning.effort: - # For Responses API, reasoning is an object - reasoning_param = { - "effort": model_settings.reasoning.effort, - } - # Add summary if specified (check both 'summary' and 'generate_summary' for compatibility) - summary_value = None - if hasattr(model_settings.reasoning, 'summary') and model_settings.reasoning.summary is not None: - summary_value = model_settings.reasoning.summary - elif ( - hasattr(model_settings.reasoning, 'generate_summary') - and model_settings.reasoning.generate_summary is not None - ): - summary_value = model_settings.reasoning.generate_summary - - if summary_value is not None: - reasoning_param["summary"] = summary_value - - logger.debug(f"[TemporalStreamingModel] Using reasoning param: {reasoning_param}") - return reasoning_param - - return NOT_GIVEN - - def _convert_tool_choice(self, tool_choice: Any) -> Any: - """Convert tool_choice to Responses API format. - - Args: - tool_choice: Tool choice from model settings - - Returns: - Converted tool choice or NOT_GIVEN - """ - if tool_choice is None: - return NOT_GIVEN - - if isinstance(tool_choice, MCPToolChoice): - # MCP tool choice with server label - return { - "server_label": tool_choice.server_label, - "type": "mcp", - "name": tool_choice.name, - } - elif tool_choice == "required": - return "required" - elif tool_choice == "auto": - return "auto" - elif tool_choice == "none": - return "none" - elif tool_choice == "file_search": - return {"type": "file_search"} - elif tool_choice == "web_search": - return {"type": "web_search"} - elif tool_choice == "web_search_preview": - return {"type": "web_search_preview"} - elif tool_choice == "computer_use_preview": - return {"type": "computer_use_preview"} - elif tool_choice == "image_generation": - return {"type": "image_generation"} - elif tool_choice == "code_interpreter": - return {"type": "code_interpreter"} - elif tool_choice == "mcp": - # Generic MCP without specific tool - return {"type": "mcp"} - elif isinstance(tool_choice, str): - # Specific function tool by name - return { - "type": "function", - "name": tool_choice, - } - else: - # Pass through as-is for other types - return tool_choice - - async def _post_tool_message(self, task_id: str, content: Any) -> None: - """Post a one-shot tool request/response message (no deltas). - - Used for hosted/server-side tool calls (web_search, file_search, - code_interpreter, image generation, server-side mcp, ...) that execute - inside the Responses API and so never produce function_call items or fire - RunHooks. Each completed hosted tool is surfaced as a ToolRequestContent + - ToolResponseContent pair. Posting full (no deltas) means the coalescing - path that the streamed reasoning/text contexts use does not apply here. - """ - try: - async with adk.streaming.streaming_task_message_context( - task_id=task_id, - initial_content=content, - ) as ctx: - await ctx.stream_update( - StreamTaskMessageFull( - parent_task_message=ctx.task_message, - content=content, - type="full", - ) - ) - except Exception as e: # noqa: BLE001 - UI surfacing must never break a turn - logger.warning(f"[TemporalStreamingModel] failed to post hosted-tool message: {e}") - - @override - async def get_response( - self, - system_instructions: Optional[str], - input: Union[str, list[TResponseInputItem]], - model_settings: ModelSettings, - tools: list[Tool], - output_schema: Optional[AgentOutputSchemaBase], - handoffs: list[Handoff], - tracing: ModelTracing, # noqa: ARG002 - *, - previous_response_id: Optional[str] = None, - conversation_id: Optional[str] = None, - prompt: Optional[ResponsePromptParam] = None, - ) -> ModelResponse: - """Get a non-streaming response from the model with streaming to Redis. - - This method is used by Temporal activities and needs to return a complete - response, but we stream the response to Redis while generating it. - - ``previous_response_id``, ``conversation_id``, and ``prompt`` are all - Responses API server-state parameters threaded through by the OpenAI - Agents SDK. Each is forwarded to ``responses.create`` only when - explicitly set โ€” defaults resolve to ``NOT_GIVEN`` and are omitted from - the request body. Not all OpenAI-compatible backends recognize these - fields, so callers on alternative providers see no wire-level change - unless they opt in. - """ - - task_id = streaming_task_id.get() - trace_id = streaming_trace_id.get() - parent_span_id = streaming_parent_span_id.get() - - if not task_id or not trace_id or not parent_span_id: - raise ValueError("task_id, trace_id, and parent_span_id are required for streaming with Responses API") - - trace = self.tracer.trace(trace_id) - - async with trace.span( - parent_id=parent_span_id, - name="streaming_model_get_response", - input={ - "model": self.model_name, - "has_system_instructions": system_instructions is not None, - "input_type": type(input).__name__, - "tools_count": len(tools) if tools else 0, - "handoffs_count": len(handoffs) if handoffs else 0, - }, - ) as span: - # Always use Responses API for streaming - if not task_id: - # If no task_id, we can't use streaming - this shouldn't happen normally - raise ValueError("task_id is required for streaming with Responses API") - - logger.info(f"[TemporalStreamingModel] Using Responses API for {self.model_name}") - - try: - # Prepare input using helper method - response_input = self._prepare_response_input(input) - - # Convert tools and handoffs using helper method - response_tools, tool_includes = self._convert_tools(tools, handoffs) - openai_tools = response_tools if response_tools else None - - # Build reasoning parameter using helper method - reasoning_param = self._build_reasoning_param(model_settings) - - # Convert tool_choice using helper method - tool_choice = self._convert_tool_choice(model_settings.tool_choice) - - # Build include list for response data - include_list = [] - # Add tool-specific includes - if tool_includes: - include_list.extend(tool_includes) - # Add user-specified includes - if model_settings.response_include: - include_list.extend(model_settings.response_include) - # Add logprobs include if top_logprobs is set - if model_settings.top_logprobs is not None: - include_list.append("message.output_text.logprobs") - # Build response format for verbosity and structured output - response_format = NOT_GIVEN - - if output_schema is not None: - # Handle structured output schema for Responses API - # The Responses API expects the schema in the 'text' parameter with a 'format' key - logger.debug(f"[TemporalStreamingModel] Converting output_schema to Responses API format") - try: - # Get the JSON schema from the output schema - schema_dict = output_schema.json_schema() - response_format = { - "format": { - "type": "json_schema", - "name": "final_output", - "schema": schema_dict, - "strict": output_schema.is_strict_json_schema() if hasattr(output_schema, 'is_strict_json_schema') else True, - } - } - logger.debug(f"[TemporalStreamingModel] Built response_format with json_schema: {response_format}") - except Exception as e: - logger.warning(f"Failed to convert output_schema: {e}") - response_format = NOT_GIVEN - - if model_settings.verbosity is not None: - if response_format is not NOT_GIVEN and isinstance(response_format, dict): - response_format["verbosity"] = model_settings.verbosity - else: - response_format = {"verbosity": model_settings.verbosity} - - # Build extra_args dict for additional parameters - extra_args = dict(model_settings.extra_args or {}) - if model_settings.top_logprobs is not None: - extra_args["top_logprobs"] = model_settings.top_logprobs - - # Opt-in prompt_cache_key: forwarded only when the caller supplies it via - # model_settings.extra_args["prompt_cache_key"]. Not all OpenAI-compatible - # endpoints recognize this parameter, so we don't auto-inject a default. - prompt_cache_key = extra_args.pop("prompt_cache_key", NOT_GIVEN) - - # Create the response stream using Responses API. - # Bookmark request start *before* the await so ttft captures the full - # user-perceived latency (HTTP round-trip + model TTFB), not just the - # post-connect event-loop delay. - stream_start_perf = time.perf_counter() - logger.debug(f"[TemporalStreamingModel] Creating response stream with Responses API") - stream = await self.client.responses.create( # type: ignore[call-overload] - - model=self.model_name, - input=response_input, - instructions=system_instructions, - tools=openai_tools or NOT_GIVEN, - stream=True, - # Temperature and sampling parameters - temperature=self._non_null_or_not_given(model_settings.temperature), - max_output_tokens=self._non_null_or_not_given(model_settings.max_tokens), - top_p=self._non_null_or_not_given(model_settings.top_p), - # Note: frequency_penalty and presence_penalty are not supported by Responses API - # Tool and reasoning parameters - reasoning=reasoning_param, - tool_choice=tool_choice, - parallel_tool_calls=self._non_null_or_not_given(model_settings.parallel_tool_calls), - # Context and truncation - truncation=self._non_null_or_not_given(model_settings.truncation), - # Response configuration (includes structured output schema) - text=response_format, - include=include_list if include_list else NOT_GIVEN, - # Metadata and storage - metadata=self._non_null_or_not_given(model_settings.metadata), - store=self._non_null_or_not_given(model_settings.store), - # Extra customization - extra_headers=model_settings.extra_headers, - extra_query=model_settings.extra_query, - extra_body=model_settings.extra_body, - prompt_cache_key=prompt_cache_key, - previous_response_id=self._non_null_or_not_given(previous_response_id), - # SDK abstract names this conversation_id; the Responses API - # endpoint kwarg is `conversation` (accepts a str id directly). - conversation=self._non_null_or_not_given(conversation_id), - prompt=self._non_null_or_not_given(prompt), - # Any additional parameters from extra_args - **extra_args, - ) - - # Process the stream of events from Responses API - output_items = [] - captured_usage = None - captured_response_id = None - current_text = "" - streaming_context = None - reasoning_context = None - reasoning_summaries = [] - reasoning_contents = [] - event_count = 0 - # ttft / ttat / tps instrumentation. ``stream_start_perf`` is set - # above, before the responses.create() await, so it captures the full - # request-to-first-token latency. ``first_token_at`` and - # ``last_token_at`` bracket the model-generation window for tps. - # ``first_answer_at`` is set on the first user-visible answer token - # (text or tool-call delta) and excludes reasoning chunks, so ttat - # measures the latency users actually perceive on reasoning models. - first_token_at: Optional[float] = None - last_token_at: Optional[float] = None - first_answer_at: Optional[float] = None - - # We expect task_id to always be provided for streaming - if not task_id: - raise ValueError("[TemporalStreamingModel] task_id is required for streaming model") - - # Process events from the Responses API stream - function_calls_in_progress = {} # Track function calls being streamed - - async for event in stream: - event_count += 1 - - # Log event type - logger.debug(f"[TemporalStreamingModel] Event {event_count}: {type(event).__name__}") - - # Bookmark first/last token-producing events for ttft and tps. - # Includes function-call argument deltas so the generation window - # covers every event type whose tokens land in usage.output_tokens. - if isinstance(event, ( - ResponseTextDeltaEvent, - ResponseReasoningTextDeltaEvent, - ResponseReasoningSummaryTextDeltaEvent, - ResponseFunctionCallArgumentsDeltaEvent, - )): - now_perf = time.perf_counter() - if first_token_at is None: - first_token_at = now_perf - last_token_at = now_perf - # ttat: first user-visible answer token (text or tool call), - # excluding reasoning chunks. Equal to ttft for non-reasoning - # models; differs by reasoning duration for reasoning models. - if first_answer_at is None and isinstance(event, ( - ResponseTextDeltaEvent, - ResponseFunctionCallArgumentsDeltaEvent, - )): - first_answer_at = now_perf - - # Handle different event types using isinstance for type safety - if isinstance(event, ResponseOutputItemAddedEvent): - # New output item (reasoning, function call, or message) - item = getattr(event, 'item', None) - output_index = getattr(event, 'output_index', 0) - - if item and getattr(item, 'type', None) == 'reasoning': - logger.debug(f"[TemporalStreamingModel] Starting reasoning item") - if not reasoning_context: - # Start a reasoning context for streaming reasoning to UI - reasoning_context = await adk.streaming.streaming_task_message_context( - task_id=task_id, - initial_content=ReasoningContent( - author="agent", - summary=[], - content=[], - type="reasoning", - style="active", - ), - streaming_mode=self.streaming_mode, - ).__aenter__() - elif item and getattr(item, 'type', None) == 'function_call': - # Open a streaming context per function call so argument - # deltas can be published incrementally. Coalescing and - # mode dispatch are handled by the streaming layer. - call_id = getattr(item, 'call_id', '') - tool_name = getattr(item, 'name', '') - call_context = await adk.streaming.streaming_task_message_context( - task_id=task_id, - initial_content=ToolRequestContent( - author="agent", - tool_call_id=call_id, - name=tool_name, - arguments={}, - ), - streaming_mode=self.streaming_mode, - ).__aenter__() - function_calls_in_progress[output_index] = { - 'id': getattr(item, 'id', ''), - 'call_id': call_id, - 'name': tool_name, - 'arguments': getattr(item, 'arguments', ''), - 'context': call_context, - } - logger.debug(f"[TemporalStreamingModel] Starting function call: {item.name}") - - elif item and getattr(item, 'type', None) == 'message': - # Track the message being streamed - streaming_context = await adk.streaming.streaming_task_message_context( - task_id=task_id, - initial_content=TextContent( - author="agent", - content="", - format="markdown", - ), - streaming_mode=self.streaming_mode, - ).__aenter__() - - elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent): - # Stream function call arguments - output_index = getattr(event, 'output_index', 0) - delta = getattr(event, 'delta', '') - - call_data = function_calls_in_progress.get(output_index) - if call_data is not None: - call_data['arguments'] += delta - call_context = call_data.get('context') - if call_context is not None: - try: - await call_context.stream_update(StreamTaskMessageDelta( - parent_task_message=call_context.task_message, - delta=ToolRequestDelta( - tool_call_id=call_data['call_id'], - name=call_data['name'], - arguments_delta=delta, - type="tool_request", - ), - type="delta", - )) - except Exception as e: - logger.warning(f"Failed to send tool request delta: {e}") - logger.debug(f"[TemporalStreamingModel] Function call args delta: {delta[:50]}...") - - elif isinstance(event, ResponseFunctionCallArgumentsDoneEvent): - # Function call arguments complete - output_index = getattr(event, 'output_index', 0) - arguments = getattr(event, 'arguments', '') - - if output_index in function_calls_in_progress: - function_calls_in_progress[output_index]['arguments'] = arguments - logger.debug(f"[TemporalStreamingModel] Function call args done") - - elif isinstance(event, (ResponseReasoningTextDeltaEvent, ResponseReasoningSummaryTextDeltaEvent, ResponseTextDeltaEvent)): - # Handle text streaming - delta = getattr(event, 'delta', '') - - if isinstance(event, ResponseReasoningSummaryTextDeltaEvent) and reasoning_context: - # Stream reasoning summary deltas - these are the actual reasoning tokens! - try: - # Use ReasoningSummaryDelta for reasoning summaries - summary_index = getattr(event, 'summary_index', 0) - delta_obj = ReasoningSummaryDelta( - summary_index=summary_index, - summary_delta=delta, - type="reasoning_summary", - ) - update = StreamTaskMessageDelta( - parent_task_message=reasoning_context.task_message, - delta=delta_obj, - type="delta", - ) - await reasoning_context.stream_update(update) - # Accumulate the reasoning summary - if len(reasoning_summaries) <= summary_index: - logger.debug(f"[TemporalStreamingModel] Extending reasoning summaries: {summary_index}") - reasoning_summaries.extend([""] * (summary_index + 1 - len(reasoning_summaries))) - reasoning_summaries[summary_index] += delta - logger.debug(f"[TemporalStreamingModel] Streamed reasoning summary: {delta[:30]}..." if len(delta) > 30 else f"[TemporalStreamingModel] Streamed reasoning summary: {delta}") - except Exception as e: - logger.warning(f"Failed to send reasoning delta: {e}") - elif isinstance(event, ResponseReasoningTextDeltaEvent) and reasoning_context: - # Regular reasoning delta (if these ever appear) - try: - delta_obj = ReasoningContentDelta( - content_index=0, - content_delta=delta, - type="reasoning_content", - ) - update = StreamTaskMessageDelta( - parent_task_message=reasoning_context.task_message, - delta=delta_obj, - type="delta", - ) - await reasoning_context.stream_update(update) - reasoning_contents.append(delta) - except Exception as e: - logger.warning(f"Failed to send reasoning delta: {e}") - elif isinstance(event, ResponseTextDeltaEvent): - # Stream regular text output - current_text += delta - try: - delta_obj = TextDelta( - type="text", - text_delta=delta, - ) - update = StreamTaskMessageDelta( - parent_task_message=streaming_context.task_message if streaming_context else None, - delta=delta_obj, - type="delta", - ) - if streaming_context: - await streaming_context.stream_update(update) - except Exception as e: - logger.warning(f"Failed to send text delta: {e}") - - elif isinstance(event, ResponseOutputItemDoneEvent): - # Output item completed - item = getattr(event, 'item', None) - output_index = getattr(event, 'output_index', 0) - - if item and getattr(item, 'type', None) == 'reasoning': - if reasoning_context and reasoning_summaries: - logger.debug(f"[TemporalStreamingModel] Reasoning itme completed, sending final update") - try: - # Send a full message update with the complete reasoning content - complete_reasoning_content = ReasoningContent( - author="agent", - summary=reasoning_summaries, # Use accumulated summaries - content=reasoning_contents if reasoning_contents else [], - type="reasoning", - style="static", - ) - - await reasoning_context.stream_update( - update=StreamTaskMessageFull( - parent_task_message=reasoning_context.task_message, - content=complete_reasoning_content, - type="full", - ), - ) - - # Close the reasoning context after sending the final update - # This matches the reference implementation pattern - await reasoning_context.close() - reasoning_context = None - logger.debug(f"[TemporalStreamingModel] Closed reasoning context after final update") - except Exception as e: - logger.warning(f"Failed to send reasoning part done update: {e}") - - elif item and getattr(item, 'type', None) == 'function_call': - # Function call completed - add to output - if output_index in function_calls_in_progress: - call_data = function_calls_in_progress[output_index] - logger.debug(f"[TemporalStreamingModel] Function call completed: {call_data['name']}") - - # Create proper function call object - tool_call = ResponseFunctionToolCall( - id=call_data['id'], - call_id=call_data['call_id'], - type="function_call", - name=call_data['name'], - arguments=call_data['arguments'], - ) - output_items.append(tool_call) - - # Emit the final ToolRequestContent and close the - # per-call streaming context. If the model produced - # invalid JSON args (truncation, hallucination), fall - # back to an empty dict so the streaming layer can - # still persist a message. - call_context = call_data.get('context') - if call_context is not None: - raw_args = call_data['arguments'] or '' - try: - parsed_args = json.loads(raw_args) if raw_args else {} - except json.JSONDecodeError: - logger.warning( - f"Failed to parse tool call arguments for {call_data['name']} " - f"(raw_args_bytes={len(raw_args)})" - ) - parsed_args = {} - try: - await call_context.stream_update(StreamTaskMessageFull( - parent_task_message=call_context.task_message, - content=ToolRequestContent( - author="agent", - tool_call_id=call_data['call_id'], - name=call_data['name'], - arguments=parsed_args, - ), - type="full", - )) - except Exception as e: - logger.warning(f"Failed to send tool request full update: {e}") - try: - await call_context.close() - except Exception as e: - logger.warning(f"Failed to close tool request context: {e}") - finally: - call_data['context'] = None - - elif item and getattr(item, 'type', None) in _HOSTED_TOOL_TYPES: - # Hosted / server-side tool call (web_search, file_search, - # code_interpreter, image generation, server-side mcp, ...). - # These run inside the Responses API: no function_call item - # and no RunHooks fire, so surface the completed call as a - # tool request + response pair (it carries the full - # query/result by the time it's done). - call_id, name, args = _hosted_tool_request(item) - await self._post_tool_message( - task_id, - ToolRequestContent( - author="agent", - tool_call_id=call_id, - name=name, - arguments=args, - ), - ) - await self._post_tool_message( - task_id, - ToolResponseContent( - author="agent", - tool_call_id=call_id, - name=name, - # Plain string, matching the function-tool response - # path (hooks.on_tool_end) so hosted and function - # tools render identically in the same flow. - content=_hosted_tool_result(item)[:_HOSTED_TOOL_RESULT_CAP], - ), - ) - - elif isinstance(event, ResponseReasoningSummaryPartAddedEvent): - # New reasoning part/summary started - reset accumulator - part = getattr(event, 'part', None) - if part: - part_type = getattr(part, 'type', 'unknown') - logger.debug(f"[TemporalStreamingModel] New reasoning part: type={part_type}") - # Reset the current reasoning summary for this new part - - elif isinstance(event, ResponseReasoningSummaryPartDoneEvent): - # Reasoning part completed - ResponseOutputItemDoneEvent will handle the final update - logger.debug(f"[TemporalStreamingModel] Reasoning part completed") - - elif isinstance(event, ResponseCompletedEvent): - # Response completed - logger.debug(f"[TemporalStreamingModel] Response completed") - response = getattr(event, 'response', None) - if response is not None: - if hasattr(response, 'output'): - # Use the final output from the response - output_items = response.output - logger.debug(f"[TemporalStreamingModel] Found {len(output_items)} output items in final response") - captured_usage = getattr(response, 'usage', None) - captured_response_id = getattr(response, 'id', None) - - # End of event processing loop - close any open contexts - if reasoning_context: - await reasoning_context.close() - reasoning_context = None - - if streaming_context: - await streaming_context.close() - streaming_context = None - - # Defensive: close any function call contexts that didn't see a - # ResponseOutputItemDoneEvent (truncated stream, error mid-call). - for call_data in function_calls_in_progress.values(): - call_context = call_data.get('context') - if call_context is not None: - try: - await call_context.close() - except Exception as e: - logger.warning(f"Failed to close orphaned tool request context: {e}") - call_data['context'] = None - - # Build the response from output items collected during streaming - # Create output from the items we collected - response_output = [] - - # Process output items from the response - if output_items: - for item in output_items: - if isinstance(item, ResponseFunctionToolCall): - response_output.append(item) - elif isinstance(item, ResponseOutputMessage): - response_output.append(item) - else: - response_output.append(item) - else: - # No output items - create empty message - message = ResponseOutputMessage( - id=f"msg_{uuid.uuid4().hex[:8]}", - type="message", - status="completed", - role="assistant", - content=[ResponseOutputText( - type="output_text", - text=current_text if current_text else "", - annotations=[] - )] - ) - response_output.append(message) - - # Use the real usage from the streaming Response if available; - # fall back to zeros only when the stream ended without a - # ResponseCompletedEvent (error paths). - if captured_usage is not None: - usage = Usage( - input_tokens=captured_usage.input_tokens, - output_tokens=captured_usage.output_tokens, - total_tokens=captured_usage.total_tokens, - input_tokens_details=InputTokensDetails( - cached_tokens=getattr( - captured_usage.input_tokens_details, "cached_tokens", 0 - ), - ), - output_tokens_details=OutputTokensDetails( - reasoning_tokens=getattr( - captured_usage.output_tokens_details, "reasoning_tokens", 0 - ), - ), - ) - else: - usage = Usage( - input_tokens=0, - output_tokens=0, - total_tokens=0, - input_tokens_details=InputTokensDetails(cached_tokens=0), - output_tokens_details=OutputTokensDetails(reasoning_tokens=0), - ) - - # Serialize response output items for span tracing - new_items = [] - final_output = None - tool_calls = [] - tool_outputs = [] - - for item in response_output: - try: - item_dict = _serialize_item(item) - if item_dict: - new_items.append(item_dict) - - # Extract final_output from message type if available - if item_dict.get('type') == 'message' and not final_output: - content = item_dict.get('content', []) - if content and isinstance(content, list): - for content_part in content: - if isinstance(content_part, dict) and 'text' in content_part: - final_output = content_part['text'] - break - except Exception as e: - logger.warning(f"Failed to serialize item in temporal_streaming_model: {e}") - continue - - # Extract tool calls and outputs from input - try: - if isinstance(input, list): - for item in input: - try: - item_dict = _serialize_item(item) if not isinstance(item, dict) else item - if item_dict: - # Capture function calls - if item_dict.get('type') == 'function_call': - tool_calls.append(item_dict) - # Capture function outputs - elif item_dict.get('type') == 'function_call_output': - tool_outputs.append(item_dict) - except Exception: - pass - except Exception as e: - logger.warning(f"Failed to extract tool calls and outputs: {e}") - - # Set span output with structured data - if span: - output_data = { - "new_items": new_items, - "final_output": final_output, - "usage": { - "input_tokens": usage.input_tokens, - "output_tokens": usage.output_tokens, - "total_tokens": usage.total_tokens, - "cached_input_tokens": usage.input_tokens_details.cached_tokens, - "reasoning_tokens": usage.output_tokens_details.reasoning_tokens, - }, - } - # Include tool calls if any were in the input - if tool_calls: - output_data["tool_calls"] = tool_calls - # Include tool outputs if any were processed - if tool_outputs: - output_data["tool_outputs"] = tool_outputs - - span.output = output_data - - # Streaming-only metrics. Token counters and the success request - # counter are emitted by LLMMetricsHooks.on_llm_end so they fire - # consistently across streaming and non-streaming paths. - m = get_llm_metrics() - metric_attrs = {"model": self.model_name} - if first_token_at is not None: - m.ttft_ms.record((first_token_at - stream_start_perf) * 1000, metric_attrs) - if first_answer_at is not None: - m.ttat_ms.record((first_answer_at - stream_start_perf) * 1000, metric_attrs) - # Single-token responses collapse the generation window to 0; tps - # is undefined and skipped. - if ( - first_token_at is not None - and last_token_at is not None - and last_token_at > first_token_at - and (usage.output_tokens or 0) > 0 - ): - m.tps.record(usage.output_tokens / (last_token_at - first_token_at), metric_attrs) - - # Return the response. response_id is the server-issued id from - # ResponseCompletedEvent.response.id, or None when the stream ended - # without a completed event (error path) โ€” matching the documented - # `str | None` contract on `ModelResponse.response_id`. Returning - # None lets callers use it safely as `previous_response_id` for - # multi-turn chaining; a fabricated UUID would 400 against any real - # server. - return ModelResponse( - output=response_output, - usage=usage, - response_id=captured_response_id, - ) - - except Exception as e: - logger.error(f"Error using Responses API: {e}") - # LLMMetricsHooks.on_llm_end doesn't fire on error, so emit the - # failure counter here. Best-effort so the typed LLM exception - # always propagates intact for retry / circuit-breaker logic. - record_llm_failure(self.model_name, e) - raise - - # The _get_response_with_responses_api method has been merged into get_response above - # All Responses API logic is now integrated directly in get_response() method - - @override - def stream_response(self, *args, **kwargs): - """Streaming is not implemented as we use the async get_response method. - This method is included for compatibility with the Model interface but should not be used. - All streaming is handled through the async get_response method with the Responses API.""" - raise NotImplementedError("stream_response is not used in Temporal activities - use get_response instead") - - -class TemporalStreamingModelProvider(ModelProvider): - """Custom model provider that returns a streaming-capable model.""" - - def __init__( - self, - openai_client: Optional[AsyncOpenAI] = None, - streaming_mode: StreamingMode = "coalesced", - ): - """Initialize the provider. - - Args: - openai_client: Optional custom AsyncOpenAI client to use for all models. - If not provided, each model will create its own default client. - streaming_mode: Default streaming mode applied to every model returned by - this provider. See ``StreamingMode`` for the meaning of - each value. Defaults to "coalesced" โ€” fast but still streamy. - """ - super().__init__() - self.openai_client = openai_client - self.streaming_mode: StreamingMode = streaming_mode - logger.info(f"[TemporalStreamingModelProvider] Initialized, custom_client={openai_client is not None}, streaming_mode={self.streaming_mode}") - - @override - def get_model(self, model_name: Union[str, None]) -> Model: - """Get a model instance with streaming capabilities. - - Args: - model_name: The name of the model to retrieve - - Returns: - A Model instance with streaming support. - """ - # Use the provided model_name or default to gpt-4o - actual_model = model_name if model_name else "gpt-4o" - logger.info(f"[TemporalStreamingModelProvider] Creating TemporalStreamingModel for model_name: {actual_model}") - model = TemporalStreamingModel( - model_name=actual_model, - openai_client=self.openai_client, - streaming_mode=self.streaming_mode, - ) - return model diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/run.py b/src/agentex/lib/core/temporal/plugins/openai_agents/run.py deleted file mode 100644 index 0fb21bfe4..000000000 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/run.py +++ /dev/null @@ -1,161 +0,0 @@ -"""``run_turn`` โ€” the unified entry point for the OpenAI Agents Temporal harness. - -This is the ``Runner.run`` analogue of the CLI harness's -``UnifiedEmitter.auto_send_turn``: it owns the repeatable per-turn concerns so -agents don't hand-roll them. - -What it does: - -1. Runs the agent via ``Runner.run`` with hooks that emit each tool call exactly - ONCE. The ``TemporalStreamingModelProvider`` already streams the tool-call - message from the model output, so the hooks are wired with - ``emit_messages=False`` to avoid the double-post; they still trace tool calls - (input + output) and emit token-usage metrics. -2. Normalizes token usage off the run result into a harness-independent - ``TurnUsage`` so callers can attach it to the turn span / task metadata, - matching what the CLI harness reports. - -What it deliberately does NOT do: sandboxing. Sandbox provisioning is a -composable concern carried on ``RunConfig`` (the SDK's ``SandboxRunConfig``) and -is passed straight through. Agent-specific lifecycle UI (e.g. surfacing sandbox -provisioning as a tool card) belongs in a caller-supplied ``hooks`` subclass, -not here. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any -from dataclasses import dataclass - -from agents import Runner - -from agentex.lib.utils.logging import make_logger -from agentex.lib.core.harness.types import TurnUsage -from agentex.lib.core.temporal.plugins.openai_agents.hooks.hooks import TemporalStreamingHooks - -if TYPE_CHECKING: - from agents import RunHooks, RunConfig - from agents.result import RunResult - -logger = make_logger(__name__) - -# Mirror the OpenAI Agents SDK default; callers can override per turn. -_DEFAULT_MAX_TURNS = 10 - - -@dataclass -class OpenAIAgentsTurnResult: - """The raw SDK run result plus normalized agentex usage. - - The raw ``result`` is kept so callers retain ``final_output``, - ``to_input_list()`` and any provider extras (e.g. sandbox resume state); - ``usage`` is the harness-independent token/cost summary for the turn span. - """ - - result: "RunResult" - usage: TurnUsage - - @property - def final_output(self) -> Any: - return self.result.final_output - - -def _extract_turn_usage(result: "RunResult", *, model: str | None = None) -> TurnUsage: - """Map the SDK's aggregated ``context_wrapper.usage`` onto ``TurnUsage``. - - Tolerant of a missing/partial Usage shape (non-OpenAI providers routed via - litellm may omit the nested token details) โ€” absent fields stay None. - """ - usage = getattr(getattr(result, "context_wrapper", None), "usage", None) - if usage is None: - return TurnUsage(model=model) - - input_details = getattr(usage, "input_tokens_details", None) - output_details = getattr(usage, "output_tokens_details", None) - return TurnUsage( - model=model, - input_tokens=getattr(usage, "input_tokens", None), - output_tokens=getattr(usage, "output_tokens", None), - total_tokens=getattr(usage, "total_tokens", None), - cached_input_tokens=getattr(input_details, "cached_tokens", None), - reasoning_tokens=getattr(output_details, "reasoning_tokens", None), - num_llm_calls=getattr(usage, "requests", None), - ) - - -async def run_turn( - starting_agent: Any, - input: Any, - *, - task_id: str, - trace_id: str | None = None, - parent_span_id: str | None = None, - run_config: "RunConfig | None" = None, - hooks: "RunHooks | None" = None, - model: str | None = None, - max_turns: int = _DEFAULT_MAX_TURNS, -) -> OpenAIAgentsTurnResult: - """Run one agent turn and return the result plus normalized usage. - - Args: - starting_agent: The agent to run. - input: The input list / string passed to ``Runner.run``. - task_id: AgentEx task id for streaming. - trace_id: When set, tool calls are traced to SGP (input + output). Only - applied when ``hooks`` is omitted (it flows into the default - ``TemporalStreamingHooks``). Ignored when you pass your own ``hooks`` - โ€” see ``hooks`` below. - parent_span_id: Parent span for the per-tool spans (typically the turn - span). Same caveat as ``trace_id``: only applied to the default hooks. - run_config: Forwarded to ``Runner.run`` verbatim (carries the model - provider and any ``SandboxRunConfig``). Left untouched here. - hooks: Optional hooks override. When omitted, a default - ``TemporalStreamingHooks(emit_tool_requests=False, ...)`` is used so - the streaming model is the sole tool-REQUEST emitter while the hooks - still emit tool RESPONSES (the model does not), and ``trace_id`` / - ``parent_span_id`` are forwarded into it. When you pass your own - subclass (also with ``emit_tool_requests=False``) to add agent-specific - lifecycle behavior such as a sandbox-ready card, ``trace_id`` and - ``parent_span_id`` are NOT applied for you โ€” pass them to your - subclass's constructor yourself if you want tool spans traced. - model: Model name recorded on the returned usage; derived from the agent - when not supplied. - max_turns: Forwarded to ``Runner.run``. - - Returns: - OpenAIAgentsTurnResult with the raw run result and normalized usage. - """ - if hooks is None: - hooks = TemporalStreamingHooks( - task_id=task_id, - # The streaming model already posts the tool REQUEST, so suppress it - # here (no double-post) โ€” but keep responses, which the model does not - # emit for function tools (on_tool_end is their only source). - emit_tool_requests=False, - emit_tool_responses=True, - trace_id=trace_id, - parent_span_id=parent_span_id, - ) - - run_kwargs: dict[str, Any] = {"hooks": hooks, "max_turns": max_turns} - if run_config is not None: - run_kwargs["run_config"] = run_config - - try: - result = await Runner.run(starting_agent, input, **run_kwargs) - finally: - # If the runner terminated mid-tool (max-turns, cancellation, SDK error), - # on_tool_end never fired for the in-flight call, leaving its span open. - # Drain any leftovers so they don't orphan in the tracing backend. - if isinstance(hooks, TemporalStreamingHooks): - await hooks.close_open_tool_spans() - - resolved_model = model - if resolved_model is None: - agent_model = getattr(starting_agent, "model", None) - resolved_model = str(agent_model) if agent_model else None - - return OpenAIAgentsTurnResult( - result=result, - usage=_extract_turn_usage(result, model=resolved_model), - ) diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/tests/__init__.py b/src/agentex/lib/core/temporal/plugins/openai_agents/tests/__init__.py 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_hosted_tools.py b/src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_hosted_tools.py deleted file mode 100644 index 066d6f2ed..000000000 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_hosted_tools.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Unit tests for hosted/server-side tool rendering helpers. - -These cover the pure extraction helpers used by TemporalStreamingModel to surface -Responses-API hosted tools (web_search, file_search, code_interpreter, mcp, ...) -as ToolRequest/ToolResponse pairs. They never become function_call items, so the -streaming loop must render them explicitly. -""" - -from __future__ import annotations - -from types import SimpleNamespace - -from openai.types.responses.response_output_item import ( - LocalShellCall, - ImageGenerationCall, - LocalShellCallAction, -) -from openai.types.responses.response_computer_tool_call import ActionClick, ResponseComputerToolCall -from openai.types.responses.response_function_web_search import ActionSearch, ResponseFunctionWebSearch - -from agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model import ( - _HOSTED_TOOL_TYPES, - _coerce_args, - _hosted_tool_result, - _hosted_tool_request, -) - - -def test_hosted_tool_types_membership(): - for t in ("web_search_call", "file_search_call", "code_interpreter_call", - "image_generation_call", "mcp_call", "computer_call", "local_shell_call"): - assert t in _HOSTED_TOOL_TYPES - assert "function_call" not in _HOSTED_TOOL_TYPES - - -def test_coerce_args_variants(): - assert _coerce_args(None) == {} - assert _coerce_args({"a": 1}) == {"a": 1} - assert _coerce_args('{"a": 1}') == {"a": 1} - assert _coerce_args("[1, 2]") == {"value": [1, 2]} - assert _coerce_args("not json") == {"raw": "not json"} - - -def test_hosted_tool_request_web_search(): - # Use the real Responses-API type to prove `action` is a genuine SDK field - # (it is on ResponseFunctionWebSearch), not a hand-crafted stand-in. - item = ResponseFunctionWebSearch( - id="ws_1", - status="completed", - type="web_search_call", - action=ActionSearch(type="search", query="agentex"), - ) - call_id, name, args = _hosted_tool_request(item) - assert call_id == "ws_1" - assert name == "web_search" # "_call" stripped - assert args["query"] == "agentex" - assert args["type"] == "search" - - -def test_hosted_tool_request_computer_call(): - item = ResponseComputerToolCall( - id="cc_1", - call_id="ccall_1", - type="computer_call", - status="completed", - pending_safety_checks=[], - action=ActionClick(type="click", button="left", x=10, y=20), - ) - call_id, name, args = _hosted_tool_request(item) - assert call_id == "cc_1" - assert name == "computer" - assert args["type"] == "click" - assert args["button"] == "left" - assert args["x"] == 10 and args["y"] == 20 - - -def test_hosted_tool_request_local_shell_call(): - item = LocalShellCall( - id="ls_1", - call_id="lscall_1", - type="local_shell_call", - status="completed", - action=LocalShellCallAction(type="exec", command=["ls", "-la"], env={}), - ) - call_id, name, args = _hosted_tool_request(item) - assert call_id == "ls_1" - assert name == "local_shell" - assert args["command"] == ["ls", "-la"] - - -def test_hosted_tool_request_mcp_uses_server_label(): - item = SimpleNamespace(type="mcp_call", id="m_1", name="search", - server_label="linear", arguments='{"q": "x"}') - call_id, name, args = _hosted_tool_request(item) - assert call_id == "m_1" - assert name == "linear.search" - assert args == {"q": "x"} - - -def test_hosted_tool_request_file_search_queries(): - item = SimpleNamespace(type="file_search_call", id="fs_1", - queries=["q1", "q2"]) - _, name, args = _hosted_tool_request(item) - assert name == "file_search" - assert args == {"queries": ["q1", "q2"]} - - -def test_hosted_tool_request_falls_back_to_generated_id(): - item = SimpleNamespace(type="code_interpreter_call", code="print(1)") - call_id, name, args = _hosted_tool_request(item) - assert call_id.startswith("hosted_") - assert name == "code_interpreter" - assert args == {"code": "print(1)"} - - -def test_hosted_tool_result_mcp_error_and_output(): - err_item = SimpleNamespace(type="mcp_call", error="boom") - assert "boom" in _hosted_tool_result(err_item) - ok_item = SimpleNamespace(type="mcp_call", error=None, output="done") - assert _hosted_tool_result(ok_item) == "done" - - -def test_hosted_tool_result_image_generation(): - item = ImageGenerationCall( - id="ig_1", - type="image_generation_call", - status="completed", - result="QUJD", # 4 chars of (fake) base64 - ) - assert _hosted_tool_result(item) == "" - - -def test_hosted_tool_result_falls_back_to_status(): - item = SimpleNamespace(type="web_search_call", status="completed") - assert _hosted_tool_result(item) == "completed" diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_run_turn_and_hooks.py b/src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_run_turn_and_hooks.py deleted file mode 100644 index 244182ac5..000000000 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_run_turn_and_hooks.py +++ /dev/null @@ -1,247 +0,0 @@ -"""Tests for the unified OpenAI-Agents turn surface. - -Covers: -- ``TemporalStreamingHooks`` message-emission gating (``emit_messages``), so the - streaming model can be the sole tool-message emitter (no double-post). -- ``TemporalStreamingHooks`` input-bearing tool spans (input = arguments, - output = result) when a ``trace_id`` is provided. -- ``run_turn`` usage extraction and default-hooks wiring. -""" - -from __future__ import annotations - -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock - -import pytest -from agents.tool_context import ToolContext - -from agentex.lib.core.temporal.plugins.openai_agents import run as run_mod -from agentex.lib.core.temporal.plugins.openai_agents.hooks import hooks as hooks_mod - -TemporalStreamingHooks = hooks_mod.TemporalStreamingHooks - - -def _tool_context(args: str = '{"query": "hi"}') -> ToolContext: - return ToolContext( - context=None, - tool_name="search", - tool_call_id="call_abc", - tool_arguments=args, - ) - - -def _tool() -> MagicMock: - tool = MagicMock() - tool.name = "search" - return tool - - -# --------------------------------------------------------------------------- # -# Argument parsing -# --------------------------------------------------------------------------- # - - -def test_parse_tool_arguments_valid_dict(): - assert TemporalStreamingHooks._parse_tool_arguments(_tool_context('{"a": 1}')) == {"a": 1} - - -def test_parse_tool_arguments_garbage_is_empty(): - assert TemporalStreamingHooks._parse_tool_arguments(_tool_context("not json")) == {} - - -def test_parse_tool_arguments_non_tool_context_is_empty(): - assert TemporalStreamingHooks._parse_tool_arguments(SimpleNamespace()) == {} - - -# --------------------------------------------------------------------------- # -# Message emission gating (the double-post fix + the response-survival guard) -# --------------------------------------------------------------------------- # - - -@pytest.mark.asyncio -async def test_defaults_stream_tool_request(monkeypatch): - exec_activity = AsyncMock() - monkeypatch.setattr(hooks_mod.workflow, "execute_activity", exec_activity) - - hooks = TemporalStreamingHooks(task_id="t1") # all emit flags default True - await hooks.on_tool_start(_tool_context(), MagicMock(), _tool()) - - exec_activity.assert_awaited_once() - # args=[task_id, ToolRequestContent.model_dump()] - _, kwargs = exec_activity.call_args - payload = kwargs["args"][1] - assert payload["name"] == "search" - assert payload["arguments"] == {"query": "hi"} - - -@pytest.mark.asyncio -async def test_requests_off_skips_request_but_keeps_response(monkeypatch): - """The streaming-model pairing: suppress the duplicate REQUEST, but the - RESPONSE must still emit (the model never emits function-tool responses).""" - exec_activity = AsyncMock() - monkeypatch.setattr(hooks_mod.workflow, "execute_activity", exec_activity) - - hooks = TemporalStreamingHooks(task_id="t1", emit_tool_requests=False, emit_tool_responses=True) - await hooks.on_tool_start(_tool_context(), MagicMock(), _tool()) - exec_activity.assert_not_awaited() # request suppressed - - await hooks.on_tool_end(_tool_context(), MagicMock(), _tool(), "the result") - exec_activity.assert_awaited_once() # response still emitted - _, kwargs = exec_activity.call_args - payload = kwargs["args"][1] - assert payload["name"] == "search" - assert payload["content"] == "the result" - - -@pytest.mark.asyncio -async def test_responses_off_skips_response(monkeypatch): - exec_activity = AsyncMock() - monkeypatch.setattr(hooks_mod.workflow, "execute_activity", exec_activity) - - hooks = TemporalStreamingHooks(task_id="t1", emit_tool_responses=False) - await hooks.on_tool_end(_tool_context(), MagicMock(), _tool(), "result") - - exec_activity.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_emit_handoffs_false_skips_handoff(monkeypatch): - exec_activity = AsyncMock() - monkeypatch.setattr(hooks_mod.workflow, "execute_activity", exec_activity) - - hooks = TemporalStreamingHooks(task_id="t1", emit_handoffs=False) - await hooks.on_handoff(MagicMock(), MagicMock(name="from"), MagicMock(name="to")) - - exec_activity.assert_not_awaited() - - -# --------------------------------------------------------------------------- # -# Input-bearing tool spans (the "traces have outputs but no inputs" fix) -# --------------------------------------------------------------------------- # - - -@pytest.mark.asyncio -async def test_tool_span_carries_input_and_output(monkeypatch): - monkeypatch.setattr(hooks_mod.workflow, "execute_activity", AsyncMock()) - span = SimpleNamespace(output=None) - start_span = AsyncMock(return_value=span) - end_span = AsyncMock() - fake_adk = SimpleNamespace(tracing=SimpleNamespace(start_span=start_span, end_span=end_span)) - monkeypatch.setattr(hooks_mod, "_get_adk", lambda: fake_adk) - - hooks = TemporalStreamingHooks( - task_id="t1", emit_tool_requests=False, trace_id="trace-1", parent_span_id="parent-1" - ) - await hooks.on_tool_start(_tool_context(), MagicMock(), _tool()) - - start_span.assert_awaited_once() - _, kwargs = start_span.call_args - assert kwargs["name"] == "search" - assert kwargs["parent_id"] == "parent-1" - assert kwargs["input"] == {"arguments": {"query": "hi"}} - - await hooks.on_tool_end(_tool_context(), MagicMock(), _tool(), "the answer") - end_span.assert_awaited_once() - assert span.output == {"result": "the answer"} - - -@pytest.mark.asyncio -async def test_no_trace_id_means_no_span(monkeypatch): - monkeypatch.setattr(hooks_mod.workflow, "execute_activity", AsyncMock()) - start_span = AsyncMock() - fake_adk = SimpleNamespace(tracing=SimpleNamespace(start_span=start_span)) - monkeypatch.setattr(hooks_mod, "_get_adk", lambda: fake_adk) - - hooks = TemporalStreamingHooks(task_id="t1", emit_tool_requests=False, trace_id=None) - await hooks.on_tool_start(_tool_context(), MagicMock(), _tool()) - - start_span.assert_not_awaited() - - -# --------------------------------------------------------------------------- # -# Usage extraction -# --------------------------------------------------------------------------- # - - -def _result_with_usage() -> SimpleNamespace: - usage = SimpleNamespace( - requests=3, - input_tokens=100, - output_tokens=40, - total_tokens=140, - input_tokens_details=SimpleNamespace(cached_tokens=20), - output_tokens_details=SimpleNamespace(reasoning_tokens=10), - ) - return SimpleNamespace(context_wrapper=SimpleNamespace(usage=usage), final_output="done") - - -def test_extract_turn_usage_maps_fields(): - usage = run_mod._extract_turn_usage(_result_with_usage(), model="openai/gpt-5.5") - assert usage.model == "openai/gpt-5.5" - assert usage.input_tokens == 100 - assert usage.output_tokens == 40 - assert usage.total_tokens == 140 - assert usage.cached_input_tokens == 20 - assert usage.reasoning_tokens == 10 - assert usage.num_llm_calls == 3 - - -def test_extract_turn_usage_missing_usage_is_tolerant(): - usage = run_mod._extract_turn_usage(SimpleNamespace(), model="m") - assert usage.model == "m" - assert usage.input_tokens is None - assert usage.num_llm_calls is None - - -# --------------------------------------------------------------------------- # -# run_turn -# --------------------------------------------------------------------------- # - - -@pytest.mark.asyncio -async def test_run_turn_returns_usage_and_passes_through_result(monkeypatch): - fake_result = _result_with_usage() - runner_run = AsyncMock(return_value=fake_result) - monkeypatch.setattr(run_mod.Runner, "run", runner_run) - - agent = SimpleNamespace(model="openai/gpt-5.5") - out = await run_mod.run_turn( - agent, - [{"role": "user", "content": "hi"}], - task_id="t1", - trace_id="trace-1", - parent_span_id="parent-1", - ) - - assert isinstance(out, run_mod.OpenAIAgentsTurnResult) - assert out.final_output == "done" - assert out.usage.total_tokens == 140 - assert out.usage.model == "openai/gpt-5.5" - - # Default hooks must be wired so the streaming model is the sole tool-REQUEST - # emitter, while the hooks still emit tool RESPONSES (the model does not). - runner_run.assert_awaited_once() - _, kwargs = runner_run.call_args - hooks = kwargs["hooks"] - assert hooks.emit_tool_requests is False - assert hooks.emit_tool_responses is True - assert hooks.trace_id == "trace-1" - assert hooks.parent_span_id == "parent-1" - - -@pytest.mark.asyncio -async def test_run_turn_respects_supplied_hooks(monkeypatch): - runner_run = AsyncMock(return_value=_result_with_usage()) - monkeypatch.setattr(run_mod.Runner, "run", runner_run) - - custom_hooks = TemporalStreamingHooks(task_id="t1", emit_tool_requests=False) - await run_mod.run_turn( - SimpleNamespace(model="m"), - "hi", - task_id="t1", - hooks=custom_hooks, - ) - - _, kwargs = runner_run.call_args - assert kwargs["hooks"] is custom_hooks diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_streaming_model.py b/src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_streaming_model.py deleted file mode 100644 index 26c0b7c4b..000000000 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/tests/test_streaming_model.py +++ /dev/null @@ -1,1427 +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, - ResponseOutputItemDoneEvent, - ResponseOutputItemAddedEvent, - ResponseFunctionCallArgumentsDoneEvent, - ResponseReasoningSummaryTextDeltaEvent, - ResponseFunctionCallArgumentsDeltaEvent, -) - - -class TestStreamingModelSettings: - """Test that all ModelSettings parameters work with Responses API""" - - @pytest.mark.asyncio - async def test_temperature_setting(self, streaming_model, _streaming_context_vars): - """Test that temperature parameter is properly passed to Responses API""" - streaming_model.client.responses.create = AsyncMock() - - # Mock the response stream - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([ - MagicMock(type="response.completed", response=MagicMock(output=[])) - ]) - streaming_model.client.responses.create.return_value = mock_stream - - # Test with various temperature values - for temp in [0.0, 0.7, 1.5, 2.0]: - settings = ModelSettings(temperature=temp) - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=settings, - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - # Verify temperature was passed correctly - create_call = streaming_model.client.responses.create.call_args - assert create_call.kwargs['temperature'] == temp - - @pytest.mark.asyncio - async def test_top_p_setting(self, streaming_model, _streaming_context_vars): - """Test that top_p parameter is properly passed to Responses API""" - streaming_model.client.responses.create = AsyncMock() - - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([ - MagicMock(type="response.completed", response=MagicMock(output=[])) - ]) - streaming_model.client.responses.create.return_value = mock_stream - - # Test with various top_p values - for top_p in [0.1, 0.5, 0.9, None]: - settings = ModelSettings(top_p=top_p) - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=settings, - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - create_call = streaming_model.client.responses.create.call_args - expected = top_p if top_p is not None else NOT_GIVEN - assert create_call.kwargs['top_p'] == expected - - @pytest.mark.asyncio - async def test_max_tokens_setting(self, streaming_model, _streaming_context_vars): - """Test that max_tokens is properly mapped to max_output_tokens""" - streaming_model.client.responses.create = AsyncMock() - - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([ - MagicMock(type="response.completed", response=MagicMock(output=[])) - ]) - streaming_model.client.responses.create.return_value = mock_stream - - settings = ModelSettings(max_tokens=2000) - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=settings, - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - create_call = streaming_model.client.responses.create.call_args - assert create_call.kwargs['max_output_tokens'] == 2000 - - @pytest.mark.asyncio - async def test_reasoning_effort_settings(self, streaming_model, _streaming_context_vars): - """Test reasoning effort levels (low/medium/high)""" - streaming_model.client.responses.create = AsyncMock() - - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([ - MagicMock(type="response.completed", response=MagicMock(output=[])) - ]) - streaming_model.client.responses.create.return_value = mock_stream - - for effort in ["low", "medium", "high"]: - settings = ModelSettings( - reasoning=Reasoning(effort=effort) - ) - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=settings, - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - create_call = streaming_model.client.responses.create.call_args - assert create_call.kwargs['reasoning'] == {"effort": effort} - - @pytest.mark.asyncio - async def test_reasoning_summary_settings(self, streaming_model, _streaming_context_vars): - """Test reasoning summary settings (auto/none)""" - streaming_model.client.responses.create = AsyncMock() - - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([ - MagicMock(type="response.completed", response=MagicMock(output=[])) - ]) - streaming_model.client.responses.create.return_value = mock_stream - - for summary in ["auto", "concise", "detailed"]: - settings = ModelSettings( - reasoning=Reasoning(effort="medium", generate_summary=summary) - ) - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=settings, - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - create_call = streaming_model.client.responses.create.call_args - assert create_call.kwargs['reasoning'] == {"effort": "medium", "summary": summary} - - @pytest.mark.asyncio - async def test_tool_choice_variations(self, streaming_model, _streaming_context_vars, sample_function_tool): - """Test various tool_choice settings""" - streaming_model.client.responses.create = AsyncMock() - - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([ - MagicMock(type="response.completed", response=MagicMock(output=[])) - ]) - streaming_model.client.responses.create.return_value = mock_stream - - # Test different tool_choice options - test_cases = [ - ("auto", "auto"), - ("required", "required"), - ("none", "none"), - ("get_weather", {"type": "function", "name": "get_weather"}), - ("web_search", {"type": "web_search"}), - (MCPToolChoice(server_label="test", name="tool"), {"server_label": "test", "type": "mcp", "name": "tool"}) - ] - - for tool_choice, expected in test_cases: - settings = ModelSettings(tool_choice=tool_choice) - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=settings, - tools=[sample_function_tool], - output_schema=None, - handoffs=[], - tracing=None, - ) - - create_call = streaming_model.client.responses.create.call_args - assert create_call.kwargs['tool_choice'] == expected - - @pytest.mark.asyncio - async def test_parallel_tool_calls(self, streaming_model, _streaming_context_vars, sample_function_tool): - """Test parallel tool calls setting""" - streaming_model.client.responses.create = AsyncMock() - - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([ - MagicMock(type="response.completed", response=MagicMock(output=[])) - ]) - streaming_model.client.responses.create.return_value = mock_stream - - for parallel in [True, False]: - settings = ModelSettings(parallel_tool_calls=parallel) - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=settings, - tools=[sample_function_tool], - output_schema=None, - handoffs=[], - tracing=None, - ) - - create_call = streaming_model.client.responses.create.call_args - assert create_call.kwargs['parallel_tool_calls'] == parallel - - @pytest.mark.asyncio - async def test_truncation_strategy(self, streaming_model, _streaming_context_vars): - """Test truncation parameter""" - streaming_model.client.responses.create = AsyncMock() - - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([ - MagicMock(type="response.completed", response=MagicMock(output=[])) - ]) - streaming_model.client.responses.create.return_value = mock_stream - - # truncation now accepts 'auto' or 'disabled' string literals - settings = ModelSettings(truncation="auto") - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=settings, - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - create_call = streaming_model.client.responses.create.call_args - assert create_call.kwargs['truncation'] == "auto" - - @pytest.mark.asyncio - async def test_response_include(self, streaming_model, _streaming_context_vars, sample_file_search_tool): - """Test response include parameter""" - streaming_model.client.responses.create = AsyncMock() - - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([ - MagicMock(type="response.completed", response=MagicMock(output=[])) - ]) - streaming_model.client.responses.create.return_value = mock_stream - - settings = ModelSettings( - response_include=["reasoning.encrypted_content", "message.output_text.logprobs"] - ) - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=settings, - tools=[sample_file_search_tool], # This adds file_search_call.results - output_schema=None, - handoffs=[], - tracing=None, - ) - - create_call = streaming_model.client.responses.create.call_args - include_list = create_call.kwargs['include'] - assert "reasoning.encrypted_content" in include_list - assert "message.output_text.logprobs" in include_list - assert "file_search_call.results" in include_list # Added by file search tool - - @pytest.mark.asyncio - async def test_verbosity(self, streaming_model, _streaming_context_vars): - """Test verbosity settings""" - streaming_model.client.responses.create = AsyncMock() - - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([ - MagicMock(type="response.completed", response=MagicMock(output=[])) - ]) - streaming_model.client.responses.create.return_value = mock_stream - - settings = ModelSettings(verbosity="high") - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=settings, - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - create_call = streaming_model.client.responses.create.call_args - assert create_call.kwargs['text'] == {"verbosity": "high"} - - @pytest.mark.asyncio - async def test_metadata_and_store(self, streaming_model, _streaming_context_vars): - """Test metadata and store parameters""" - streaming_model.client.responses.create = AsyncMock() - - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([ - MagicMock(type="response.completed", response=MagicMock(output=[])) - ]) - streaming_model.client.responses.create.return_value = mock_stream - - metadata = {"user_id": "123", "session": "abc"} - store = True - - settings = ModelSettings( - metadata=metadata, - store=store - ) - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=settings, - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - create_call = streaming_model.client.responses.create.call_args - assert create_call.kwargs['metadata'] == metadata - assert create_call.kwargs['store'] == store - - @pytest.mark.asyncio - async def test_extra_headers_and_body(self, streaming_model, _streaming_context_vars): - """Test extra customization parameters""" - streaming_model.client.responses.create = AsyncMock() - - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([ - MagicMock(type="response.completed", response=MagicMock(output=[])) - ]) - streaming_model.client.responses.create.return_value = mock_stream - - extra_headers = {"X-Custom": "header"} - extra_body = {"custom_field": "value"} - extra_query = {"param": "value"} - - settings = ModelSettings( - extra_headers=extra_headers, - extra_body=extra_body, - extra_query=extra_query - ) - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=settings, - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - create_call = streaming_model.client.responses.create.call_args - assert create_call.kwargs['extra_headers'] == extra_headers - assert create_call.kwargs['extra_body'] == extra_body - assert create_call.kwargs['extra_query'] == extra_query - - @pytest.mark.asyncio - async def test_top_logprobs(self, streaming_model, _streaming_context_vars): - """Test top_logprobs parameter""" - streaming_model.client.responses.create = AsyncMock() - - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([ - MagicMock(type="response.completed", response=MagicMock(output=[])) - ]) - streaming_model.client.responses.create.return_value = mock_stream - - settings = ModelSettings(top_logprobs=5) - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=settings, - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - create_call = streaming_model.client.responses.create.call_args - # top_logprobs goes into extra_args - assert "top_logprobs" in create_call.kwargs - assert create_call.kwargs['top_logprobs'] == 5 - # Also should add to include list - assert "message.output_text.logprobs" in create_call.kwargs['include'] - - -class TestStreamingModelTools: - """Test that all tool types work with streaming""" - - @pytest.mark.asyncio - async def test_function_tool(self, streaming_model, _streaming_context_vars, sample_function_tool): - """Test FunctionTool conversion and streaming""" - streaming_model.client.responses.create = AsyncMock() - - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([ - MagicMock(type="response.completed", response=MagicMock(output=[])) - ]) - streaming_model.client.responses.create.return_value = mock_stream - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=ModelSettings(), - tools=[sample_function_tool], - output_schema=None, - handoffs=[], - tracing=None, - ) - - create_call = streaming_model.client.responses.create.call_args - tools = create_call.kwargs['tools'] - assert len(tools) == 1 - assert tools[0]['type'] == 'function' - assert tools[0]['name'] == 'get_weather' - assert tools[0]['description'] == 'Get the current weather' - assert 'parameters' in tools[0] - - @pytest.mark.asyncio - async def test_web_search_tool(self, streaming_model, _streaming_context_vars, sample_web_search_tool): - """Test WebSearchTool conversion""" - streaming_model.client.responses.create = AsyncMock() - - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([ - MagicMock(type="response.completed", response=MagicMock(output=[])) - ]) - streaming_model.client.responses.create.return_value = mock_stream - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=ModelSettings(), - tools=[sample_web_search_tool], - output_schema=None, - handoffs=[], - tracing=None, - ) - - create_call = streaming_model.client.responses.create.call_args - tools = create_call.kwargs['tools'] - assert len(tools) == 1 - assert tools[0]['type'] == 'web_search' - - @pytest.mark.asyncio - async def test_file_search_tool(self, streaming_model, _streaming_context_vars, sample_file_search_tool): - """Test FileSearchTool conversion""" - streaming_model.client.responses.create = AsyncMock() - - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([ - MagicMock(type="response.completed", response=MagicMock(output=[])) - ]) - streaming_model.client.responses.create.return_value = mock_stream - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=ModelSettings(), - tools=[sample_file_search_tool], - output_schema=None, - handoffs=[], - tracing=None, - ) - - create_call = streaming_model.client.responses.create.call_args - tools = create_call.kwargs['tools'] - assert len(tools) == 1 - assert tools[0]['type'] == 'file_search' - assert tools[0]['vector_store_ids'] == ['vs_123'] - assert tools[0]['max_num_results'] == 10 - - @pytest.mark.asyncio - async def test_computer_tool(self, streaming_model, _streaming_context_vars, sample_computer_tool): - """Test ComputerTool conversion""" - streaming_model.client.responses.create = AsyncMock() - - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([ - MagicMock(type="response.completed", response=MagicMock(output=[])) - ]) - streaming_model.client.responses.create.return_value = mock_stream - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=ModelSettings(), - tools=[sample_computer_tool], - output_schema=None, - handoffs=[], - tracing=None, - ) - - create_call = streaming_model.client.responses.create.call_args - tools = create_call.kwargs['tools'] - assert len(tools) == 1 - assert tools[0]['type'] == 'computer_use_preview' - assert tools[0]['environment'] == 'desktop' - assert tools[0]['display_width'] == 1920 - assert tools[0]['display_height'] == 1080 - - @pytest.mark.asyncio - async def test_multiple_computer_tools_error(self, streaming_model, _streaming_context_vars, sample_computer_tool): - """Test that multiple computer tools raise an error""" - streaming_model.client.responses.create = AsyncMock() - - # Create two computer tools - computer2 = MagicMock() - computer2.environment = "mobile" - computer2.dimensions = [375, 812] - from agents.tool import ComputerTool - second_computer_tool = ComputerTool(computer=computer2) - - with pytest.raises(ValueError, match="You can only provide one computer tool"): - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=ModelSettings(), - tools=[sample_computer_tool, second_computer_tool], - output_schema=None, - handoffs=[], - tracing=None, - ) - - @pytest.mark.asyncio - async def test_hosted_mcp_tool(self, streaming_model, _streaming_context_vars, sample_hosted_mcp_tool): - """Test HostedMCPTool conversion""" - streaming_model.client.responses.create = AsyncMock() - - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([ - MagicMock(type="response.completed", response=MagicMock(output=[])) - ]) - streaming_model.client.responses.create.return_value = mock_stream - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=ModelSettings(), - tools=[sample_hosted_mcp_tool], - output_schema=None, - handoffs=[], - tracing=None, - ) - - create_call = streaming_model.client.responses.create.call_args - tools = create_call.kwargs['tools'] - assert len(tools) == 1 - assert tools[0]['type'] == 'mcp' - assert tools[0]['server_label'] == 'test_server' - - @pytest.mark.asyncio - async def test_image_generation_tool(self, streaming_model, _streaming_context_vars, sample_image_generation_tool): - """Test ImageGenerationTool conversion""" - streaming_model.client.responses.create = AsyncMock() - - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([ - MagicMock(type="response.completed", response=MagicMock(output=[])) - ]) - streaming_model.client.responses.create.return_value = mock_stream - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=ModelSettings(), - tools=[sample_image_generation_tool], - output_schema=None, - handoffs=[], - tracing=None, - ) - - create_call = streaming_model.client.responses.create.call_args - tools = create_call.kwargs['tools'] - assert len(tools) == 1 - assert tools[0]['type'] == 'image_generation' - - @pytest.mark.asyncio - async def test_code_interpreter_tool(self, streaming_model, _streaming_context_vars, sample_code_interpreter_tool): - """Test CodeInterpreterTool conversion""" - streaming_model.client.responses.create = AsyncMock() - - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([ - MagicMock(type="response.completed", response=MagicMock(output=[])) - ]) - streaming_model.client.responses.create.return_value = mock_stream - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=ModelSettings(), - tools=[sample_code_interpreter_tool], - output_schema=None, - handoffs=[], - tracing=None, - ) - - create_call = streaming_model.client.responses.create.call_args - tools = create_call.kwargs['tools'] - assert len(tools) == 1 - assert tools[0]['type'] == 'code_interpreter' - - @pytest.mark.asyncio - async def test_local_shell_tool(self, streaming_model, _streaming_context_vars, sample_local_shell_tool): - """Test LocalShellTool conversion""" - streaming_model.client.responses.create = AsyncMock() - - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([ - MagicMock(type="response.completed", response=MagicMock(output=[])) - ]) - streaming_model.client.responses.create.return_value = mock_stream - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=ModelSettings(), - tools=[sample_local_shell_tool], - output_schema=None, - handoffs=[], - tracing=None, - ) - - create_call = streaming_model.client.responses.create.call_args - tools = create_call.kwargs['tools'] - assert len(tools) == 1 - assert tools[0]['type'] == 'local_shell' - # working_directory no longer in API - LocalShellTool uses executor internally - - @pytest.mark.asyncio - async def test_handoffs(self, streaming_model, _streaming_context_vars, sample_handoff): - """Test Handoff conversion to function tools""" - streaming_model.client.responses.create = AsyncMock() - - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([ - MagicMock(type="response.completed", response=MagicMock(output=[])) - ]) - streaming_model.client.responses.create.return_value = mock_stream - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=ModelSettings(), - tools=[], - output_schema=None, - handoffs=[sample_handoff], - tracing=None, - ) - - create_call = streaming_model.client.responses.create.call_args - tools = create_call.kwargs['tools'] - assert len(tools) == 1 - assert tools[0]['type'] == 'function' - assert tools[0]['name'] == 'transfer_to_support' - assert tools[0]['description'] == 'Transfer to support agent' - - @pytest.mark.asyncio - async def test_mixed_tools(self, streaming_model, _streaming_context_vars, - sample_function_tool, sample_web_search_tool, sample_handoff): - """Test multiple tools together""" - streaming_model.client.responses.create = AsyncMock() - - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([ - MagicMock(type="response.completed", response=MagicMock(output=[])) - ]) - streaming_model.client.responses.create.return_value = mock_stream - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=ModelSettings(), - tools=[sample_function_tool, sample_web_search_tool], - output_schema=None, - handoffs=[sample_handoff], - tracing=None, - ) - - create_call = streaming_model.client.responses.create.call_args - tools = create_call.kwargs['tools'] - assert len(tools) == 3 # 2 tools + 1 handoff - - # Check each tool type is present - tool_types = [t['type'] for t in tools] - assert 'function' in tool_types # function tool and handoff - assert 'web_search' in tool_types - - -class TestStreamingModelBasics: - """Test core streaming functionality""" - - @pytest.mark.asyncio - async def test_responses_api_streaming(self, streaming_model, mock_adk_streaming, _streaming_context_vars, sample_task_id): - """Test basic Responses API streaming flow""" - streaming_model.client.responses.create = AsyncMock() - - # Production uses ``isinstance(event, ...)`` against the OpenAI Responses - # event types to dispatch. ``spec=...`` makes isinstance pass without - # triggering pydantic validation on partially-constructed events. - item_added = MagicMock(spec=ResponseOutputItemAddedEvent) - item_added.item = MagicMock(type="message") - item_added.output_index = 0 - text_delta_1 = MagicMock(spec=ResponseTextDeltaEvent) - text_delta_1.delta = "Hello " - text_delta_2 = MagicMock(spec=ResponseTextDeltaEvent) - text_delta_2.delta = "world!" - completed = MagicMock(spec=ResponseCompletedEvent) - completed.response = MagicMock(output=[], usage=MagicMock(), id=None) - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([item_added, text_delta_1, text_delta_2, completed]) - streaming_model.client.responses.create.return_value = mock_stream - - result = await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=ModelSettings(), - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - # Verify streaming context was created with the right task_id. We - # don't strict-match the full kwargs because production also passes - # ``streaming_mode``, which is an implementation detail this test - # doesn't care about. - mock_adk_streaming.streaming_task_message_context.assert_called() - call_kwargs = mock_adk_streaming.streaming_task_message_context.call_args.kwargs - assert call_kwargs['task_id'] == sample_task_id - - # Verify result is returned as ModelResponse - from agents import ModelResponse - assert isinstance(result, ModelResponse) - - @pytest.mark.asyncio - async def test_task_id_threading(self, streaming_model, mock_adk_streaming, _streaming_context_vars): - """Test that task_id from the streaming ContextVar is threaded through to - the streaming context. ``_streaming_context_vars`` yields the task_id that - was set on the ContextVar, which is what production reads (the kwarg - ``task_id=...`` to ``get_response`` is swallowed by ``**kwargs`` and ignored). - """ - streaming_model.client.responses.create = AsyncMock() - - item_added = MagicMock(spec=ResponseOutputItemAddedEvent) - item_added.item = MagicMock(type="message") - item_added.output_index = 0 - completed = MagicMock(spec=ResponseCompletedEvent) - completed.response = MagicMock(output=[], usage=MagicMock(), id=None) - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([item_added, completed]) - streaming_model.client.responses.create.return_value = mock_stream - - expected_task_id = _streaming_context_vars - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=ModelSettings(), - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - # Verify the ContextVar's task_id was threaded through to the streaming context - mock_adk_streaming.streaming_task_message_context.assert_called() - call_args = mock_adk_streaming.streaming_task_message_context.call_args - assert call_args.kwargs['task_id'] == expected_task_id - - @pytest.mark.asyncio - async def test_redis_context_creation(self, streaming_model, mock_adk_streaming, _streaming_context_vars): - """Test that Redis streaming contexts are created properly""" - streaming_model.client.responses.create = AsyncMock() - - # Production uses ``isinstance`` against OpenAI Responses event types; - # ``spec=...`` makes isinstance pass without triggering pydantic validation. - item_added = MagicMock(spec=ResponseOutputItemAddedEvent) - item_added.item = MagicMock(type="reasoning") - item_added.output_index = 0 - reasoning_delta = MagicMock(spec=ResponseReasoningSummaryTextDeltaEvent) - reasoning_delta.delta = "Thinking..." - reasoning_delta.summary_index = 0 - completed = MagicMock(spec=ResponseCompletedEvent) - completed.response = MagicMock(output=[], usage=MagicMock(), id=None) - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter([item_added, reasoning_delta, completed]) - streaming_model.client.responses.create.return_value = mock_stream - - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=ModelSettings(reasoning=Reasoning(effort="medium")), - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - # Should create at least one context for reasoning - assert mock_adk_streaming.streaming_task_message_context.call_count >= 1 - - @pytest.mark.asyncio - async def test_missing_task_id_error(self, streaming_model): - """Test that missing streaming ContextVars raise an appropriate error. - - Production reads task_id, trace_id, and parent_span_id from ContextVars - populated by ContextInterceptor. Without ``_streaming_context_vars`` - requested, all three are at their defaults โ€” empty strings โ€” and - ``get_response`` raises before doing any work. - """ - streaming_model.client.responses.create = AsyncMock() - - with pytest.raises(ValueError, match=r"task_id.*required"): - await streaming_model.get_response( - system_instructions="Test", - input="Hello", - model_settings=ModelSettings(), - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - -class TestStreamingModelFunctionCallArgsStreaming: - """Verify ``ResponseFunctionCallArgumentsDeltaEvent``s are surfaced as - ``ToolRequestDelta`` updates and that a final ``ToolRequestContent`` Full is - emitted on ``ResponseOutputItemDoneEvent``. - - Without this, write-heavy tools (``write_file``, ``apply_patch``) buffer their - entire argument body inside ``invoke_model_activity`` and the UI sees a - multi-second freeze while the model is actively producing tokens. - """ - - @staticmethod - def _build_function_call_stream(arguments_text: str): - """Construct a streaming event sequence for a single function_call. - - Mirrors the production order: Added โ†’ N ร— ArgumentsDelta โ†’ ArgumentsDone - โ†’ OutputItemDone โ†’ ResponseCompleted. ``spec=...`` makes ``isinstance`` - dispatch in production work without triggering pydantic validation. - """ - call_item = MagicMock() - call_item.type = "function_call" - call_item.id = "fc_abc" - call_item.call_id = "call_abc" - call_item.name = "write_file" - call_item.arguments = "" - - item_added = MagicMock(spec=ResponseOutputItemAddedEvent) - item_added.item = call_item - item_added.output_index = 0 - - # Split the argument text into a few chunks to exercise the per-delta loop - chunk_size = max(1, len(arguments_text) // 3) if arguments_text else 1 - chunks = [arguments_text[i:i + chunk_size] for i in range(0, len(arguments_text), chunk_size)] or [""] - delta_events = [] - for chunk in chunks: - ev = MagicMock(spec=ResponseFunctionCallArgumentsDeltaEvent) - ev.delta = chunk - ev.output_index = 0 - delta_events.append(ev) - - args_done = MagicMock(spec=ResponseFunctionCallArgumentsDoneEvent) - args_done.arguments = arguments_text - args_done.output_index = 0 - - item_done = MagicMock(spec=ResponseOutputItemDoneEvent) - item_done.item = call_item - item_done.output_index = 0 - - completed = MagicMock(spec=ResponseCompletedEvent) - completed.response = MagicMock(output=[], usage=MagicMock(), id=None) - - return [item_added, *delta_events, args_done, item_done, completed], chunks - - @staticmethod - def _install_real_task_message(mock_adk_streaming, task_id: str): - """Replace the autouse fixture's MagicMock ``task_message`` with a real - ``TaskMessage`` so production's ``StreamTaskMessageDelta(parent_task_message=...)`` - construction passes pydantic validation. The default mock works for tests - that only assert on the context's ``__aenter__`` call but breaks tests - that exercise ``stream_update`` end-to-end. - """ - from agentex.types.task_message import TaskMessage - from agentex.types.task_message_content import ToolRequestContent - - ctx = mock_adk_streaming.streaming_task_message_context.return_value - ctx.task_message = TaskMessage( - id="msg_test", - task_id=task_id, - content=ToolRequestContent( - author="agent", - tool_call_id="call_abc", - name="write_file", - arguments={}, - ), - streaming_status="IN_PROGRESS", - ) - return ctx - - @pytest.mark.asyncio - async def test_function_call_emits_argument_deltas_and_final_full( - self, streaming_model, mock_adk_streaming, _streaming_context_vars, sample_task_id - ): - """A function_call with well-formed JSON args should produce: - (1) one streaming context opened with ``ToolRequestContent`` initial_content, - (2) one ``StreamTaskMessageDelta`` per ``ArgumentsDelta`` event carrying a - ``ToolRequestDelta`` with the right ``tool_call_id`` and ``arguments_delta``, - (3) one final ``StreamTaskMessageFull`` with ``ToolRequestContent`` whose - ``arguments`` is the parsed JSON dict. - """ - from agentex.types.task_message_delta import ToolRequestDelta - from agentex.types.task_message_update import StreamTaskMessageFull, StreamTaskMessageDelta - from agentex.types.task_message_content import ToolRequestContent - - ctx = self._install_real_task_message(mock_adk_streaming, sample_task_id) - - args_text = '{"path": "/tmp/foo.txt", "contents": "hello world"}' - events, chunks = self._build_function_call_stream(args_text) - - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter(events) - streaming_model.client.responses.create = AsyncMock(return_value=mock_stream) - - await streaming_model.get_response( - system_instructions=None, - input="please write foo", - model_settings=ModelSettings(), - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - # 1. A streaming context was opened with ToolRequestContent. - opens = [ - c for c in mock_adk_streaming.streaming_task_message_context.call_args_list - if isinstance(c.kwargs.get("initial_content"), ToolRequestContent) - ] - assert len(opens) == 1, f"expected one ToolRequest context, got {len(opens)}" - initial = opens[0].kwargs["initial_content"] - assert initial.tool_call_id == "call_abc" - assert initial.name == "write_file" - - # 2. One StreamTaskMessageDelta(ToolRequestDelta) was streamed per - # ArgumentsDelta event, preserving the delta text exactly. - delta_updates = [ - call.args[0] if call.args else call.kwargs.get("update") - for call in ctx.stream_update.call_args_list - if (call.args and isinstance(call.args[0], StreamTaskMessageDelta) - and isinstance(call.args[0].delta, ToolRequestDelta)) - ] - assert len(delta_updates) == len(chunks) - for update, expected_chunk in zip(delta_updates, chunks): - assert update.delta.tool_call_id == "call_abc" - assert update.delta.name == "write_file" - assert update.delta.arguments_delta == expected_chunk - - # 3. A final StreamTaskMessageFull(ToolRequestContent) was streamed with - # parsed args. - full_updates = [ - call.args[0] if call.args else call.kwargs.get("update") - for call in ctx.stream_update.call_args_list - if (call.args and isinstance(call.args[0], StreamTaskMessageFull) - and isinstance(call.args[0].content, ToolRequestContent)) - ] - assert len(full_updates) == 1 - final = full_updates[0].content - assert final.tool_call_id == "call_abc" - assert final.name == "write_file" - assert final.arguments == {"path": "/tmp/foo.txt", "contents": "hello world"} - - @pytest.mark.asyncio - async def test_function_call_malformed_args_fall_back_to_empty_dict( - self, streaming_model, mock_adk_streaming, _streaming_context_vars, sample_task_id, caplog - ): - """If the model produces invalid JSON for the args, the final - ``ToolRequestContent`` should carry ``arguments={}`` and a warning should - be logged. The raw delta stream is preserved either way. - """ - from agentex.types.task_message_update import StreamTaskMessageFull - from agentex.types.task_message_content import ToolRequestContent - - ctx = self._install_real_task_message(mock_adk_streaming, sample_task_id) - - # Missing closing brace โ€” invalid JSON. - events, _ = self._build_function_call_stream('{"path": "/tmp/foo.txt", "contents":') - - mock_stream = AsyncMock() - mock_stream.__aiter__.return_value = iter(events) - streaming_model.client.responses.create = AsyncMock(return_value=mock_stream) - - with caplog.at_level("WARNING"): - await streaming_model.get_response( - system_instructions=None, - input="please write foo", - model_settings=ModelSettings(), - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - full_updates = [ - call.args[0] if call.args else call.kwargs.get("update") - for call in ctx.stream_update.call_args_list - if (call.args and isinstance(call.args[0], StreamTaskMessageFull) - and isinstance(call.args[0].content, ToolRequestContent)) - ] - assert len(full_updates) == 1 - assert full_updates[0].content.arguments == {} - assert any("Failed to parse tool call arguments" in r.getMessage() for r in caplog.records) - - -class TestStreamingModelUsageResponseIdAndCacheKey: - """Cover real-Usage capture, real response_id, span emission, and opt-in prompt_cache_key.""" - - @staticmethod - def _async_iter(events): - async def _gen(): - for event in events: - yield event - return _gen() - - @staticmethod - def _make_response_completed_event( - *, - input_tokens: int = 0, - output_tokens: int = 0, - total_tokens: int = 0, - cached_tokens: int = 0, - reasoning_tokens: int = 0, - with_usage: bool = True, - response_id: Optional[str] = "resp_real_server_id", - ): - usage = MagicMock() - usage.input_tokens = input_tokens - usage.output_tokens = output_tokens - usage.total_tokens = total_tokens - usage.input_tokens_details = MagicMock(cached_tokens=cached_tokens) - usage.output_tokens_details = MagicMock(reasoning_tokens=reasoning_tokens) - - response = MagicMock() - response.output = [] - response.usage = usage if with_usage else None - response.id = response_id - - event = MagicMock(spec=ResponseCompletedEvent) - event.response = response - return event - - @pytest.fixture - def mock_span(self): - return MagicMock() - - @pytest.fixture - def streaming_model_with_mock_tracer(self, streaming_model, mock_span): - """A streaming_model whose tracer.trace().span(...) yields a captured mock span.""" - async_cm = MagicMock() - async_cm.__aenter__ = AsyncMock(return_value=mock_span) - async_cm.__aexit__ = AsyncMock(return_value=False) - trace_obj = MagicMock() - trace_obj.span = MagicMock(return_value=async_cm) - streaming_model.tracer = MagicMock() - streaming_model.tracer.trace = MagicMock(return_value=trace_obj) - return streaming_model - - @pytest.mark.asyncio - async def test_usage_captured_from_completed_event( - self, - streaming_model_with_mock_tracer, - _streaming_context_vars, # noqa: ARG002 - ): - model = streaming_model_with_mock_tracer - completed = self._make_response_completed_event( - input_tokens=1234, output_tokens=56, total_tokens=1290, - cached_tokens=987, reasoning_tokens=42, - ) - model.client.responses.create = AsyncMock(return_value=self._async_iter([completed])) - - response = await model.get_response( - system_instructions=None, - input="hi", - model_settings=ModelSettings(), - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - assert response.usage.input_tokens == 1234 - assert response.usage.output_tokens == 56 - assert response.usage.total_tokens == 1290 - assert response.usage.input_tokens_details.cached_tokens == 987 - assert response.usage.output_tokens_details.reasoning_tokens == 42 - - @pytest.mark.asyncio - async def test_usage_falls_back_when_no_completed_event( - self, - streaming_model_with_mock_tracer, - _streaming_context_vars, # noqa: ARG002 - ): - """Stream ending without a ResponseCompletedEvent (error path) โ†’ zero Usage.""" - model = streaming_model_with_mock_tracer - model.client.responses.create = AsyncMock(return_value=self._async_iter([])) - - response = await model.get_response( - system_instructions=None, - input="hi", - model_settings=ModelSettings(), - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - assert response.usage.input_tokens == 0 - assert response.usage.output_tokens == 0 - assert response.usage.total_tokens == 0 - assert response.usage.input_tokens_details.cached_tokens == 0 - assert response.usage.output_tokens_details.reasoning_tokens == 0 - - @pytest.mark.asyncio - async def test_usage_emitted_in_span_output( - self, - streaming_model_with_mock_tracer, - _streaming_context_vars, # noqa: ARG002 - mock_span, - ): - model = streaming_model_with_mock_tracer - completed = self._make_response_completed_event( - input_tokens=100, output_tokens=10, total_tokens=110, - cached_tokens=80, reasoning_tokens=5, - ) - model.client.responses.create = AsyncMock(return_value=self._async_iter([completed])) - - await model.get_response( - system_instructions=None, - input="hi", - model_settings=ModelSettings(), - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - assert isinstance(mock_span.output, dict) - usage_block = mock_span.output["usage"] - assert usage_block == { - "input_tokens": 100, - "output_tokens": 10, - "total_tokens": 110, - "cached_input_tokens": 80, - "reasoning_tokens": 5, - } - - @pytest.mark.asyncio - async def test_response_id_captured_from_completed_event( - self, - streaming_model_with_mock_tracer, - _streaming_context_vars, # noqa: ARG002 - ): - """Real server-issued id flows back on ModelResponse.response_id.""" - model = streaming_model_with_mock_tracer - completed = self._make_response_completed_event(response_id="resp_abcdef123456") - model.client.responses.create = AsyncMock(return_value=self._async_iter([completed])) - - response = await model.get_response( - system_instructions=None, - input="hi", - model_settings=ModelSettings(), - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - assert response.response_id == "resp_abcdef123456" - - @pytest.mark.asyncio - async def test_response_id_is_none_when_no_completed_event( - self, - streaming_model_with_mock_tracer, - _streaming_context_vars, # noqa: ARG002 - ): - """Stream ending without ResponseCompletedEvent โ†’ response_id is None. - - Critical: must NOT fabricate a UUID. Returning a fake id would cause - downstream `previous_response_id` chaining to 400 against the server. - """ - model = streaming_model_with_mock_tracer - model.client.responses.create = AsyncMock(return_value=self._async_iter([])) - - response = await model.get_response( - system_instructions=None, - input="hi", - model_settings=ModelSettings(), - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - assert response.response_id is None - - @pytest.mark.asyncio - async def test_prompt_cache_key_not_sent_by_default( - self, - streaming_model_with_mock_tracer, - _streaming_context_vars, # noqa: ARG002 - ): - """Without an opt-in, prompt_cache_key resolves to NOT_GIVEN (omitted from request).""" - model = streaming_model_with_mock_tracer - completed = self._make_response_completed_event() - model.client.responses.create = AsyncMock(return_value=self._async_iter([completed])) - - await model.get_response( - system_instructions=None, - input="hi", - model_settings=ModelSettings(), - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - kwargs = model.client.responses.create.call_args.kwargs - assert kwargs["prompt_cache_key"] is NOT_GIVEN - - @pytest.mark.asyncio - async def test_prompt_cache_key_forwarded_when_opted_in( - self, - streaming_model_with_mock_tracer, - _streaming_context_vars, # noqa: ARG002 - ): - """Caller opt-in via model_settings.extra_args is forwarded to responses.create.""" - model = streaming_model_with_mock_tracer - completed = self._make_response_completed_event() - model.client.responses.create = AsyncMock(return_value=self._async_iter([completed])) - - await model.get_response( - system_instructions=None, - input="hi", - model_settings=ModelSettings(extra_args={"prompt_cache_key": "my-key"}), - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - kwargs = model.client.responses.create.call_args.kwargs - assert kwargs["prompt_cache_key"] == "my-key" - # Must be popped from extra_args so the SDK doesn't see it twice. - assert list(kwargs).count("prompt_cache_key") == 1 - - @pytest.mark.asyncio - async def test_previous_response_id_not_sent_by_default( - self, - streaming_model_with_mock_tracer, - _streaming_context_vars, # noqa: ARG002 - ): - """Without an opt-in, previous_response_id resolves to NOT_GIVEN. - - Critical for non-Responses-API-native backends (e.g. Claude-via-LiteLLM) - where unknown fields on the request body could be rejected. NOT_GIVEN - is filtered before serialization, so the field is omitted entirely. - """ - model = streaming_model_with_mock_tracer - completed = self._make_response_completed_event() - model.client.responses.create = AsyncMock(return_value=self._async_iter([completed])) - - await model.get_response( - system_instructions=None, - input="hi", - model_settings=ModelSettings(), - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - kwargs = model.client.responses.create.call_args.kwargs - assert kwargs["previous_response_id"] is NOT_GIVEN - - @pytest.mark.asyncio - async def test_previous_response_id_forwarded_via_sdk_kwarg( - self, - streaming_model_with_mock_tracer, - _streaming_context_vars, # noqa: ARG002 - ): - """The SDK threads previous_response_id as a keyword arg per Model.get_response - abstract contract. Verify it reaches responses.create instead of being silently - swallowed (which was the prior behavior under **kwargs).""" - model = streaming_model_with_mock_tracer - completed = self._make_response_completed_event() - model.client.responses.create = AsyncMock(return_value=self._async_iter([completed])) - - await model.get_response( - system_instructions=None, - input="hi", - model_settings=ModelSettings(), - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - previous_response_id="resp_prior_turn", - ) - - kwargs = model.client.responses.create.call_args.kwargs - assert kwargs["previous_response_id"] == "resp_prior_turn" - - @pytest.mark.asyncio - async def test_conversation_and_prompt_not_sent_by_default( - self, - streaming_model_with_mock_tracer, - _streaming_context_vars, # noqa: ARG002 - ): - """Without an opt-in, conversation/prompt resolve to NOT_GIVEN. - - Same opt-in pattern as previous_response_id and prompt_cache_key โ€” the - wire request is unchanged for callers (and non-OpenAI backends) that - don't supply these. - """ - model = streaming_model_with_mock_tracer - completed = self._make_response_completed_event() - model.client.responses.create = AsyncMock(return_value=self._async_iter([completed])) - - await model.get_response( - system_instructions=None, - input="hi", - model_settings=ModelSettings(), - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - kwargs = model.client.responses.create.call_args.kwargs - assert kwargs["conversation"] is NOT_GIVEN - assert kwargs["prompt"] is NOT_GIVEN - - @pytest.mark.asyncio - async def test_conversation_id_forwarded_via_sdk_kwarg( - self, - streaming_model_with_mock_tracer, - _streaming_context_vars, # noqa: ARG002 - ): - """The SDK abstract names this `conversation_id`; the Responses API - endpoint kwarg is `conversation`. Caller passes a string id; we forward - it as-is (the Conversation type accepts str).""" - model = streaming_model_with_mock_tracer - completed = self._make_response_completed_event() - model.client.responses.create = AsyncMock(return_value=self._async_iter([completed])) - - await model.get_response( - system_instructions=None, - input="hi", - model_settings=ModelSettings(), - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - conversation_id="conv_abc123", - ) - - kwargs = model.client.responses.create.call_args.kwargs - assert kwargs["conversation"] == "conv_abc123" - - @pytest.mark.asyncio - async def test_prompt_forwarded_via_sdk_kwarg( - self, - streaming_model_with_mock_tracer, - _streaming_context_vars, # noqa: ARG002 - ): - """ResponsePromptParam (a TypedDict for pre-built prompts) is forwarded - as-is to responses.create.""" - model = streaming_model_with_mock_tracer - completed = self._make_response_completed_event() - model.client.responses.create = AsyncMock(return_value=self._async_iter([completed])) - - prompt_param = {"id": "prompt_test_id", "version": "1"} - await model.get_response( - system_instructions=None, - input="hi", - model_settings=ModelSettings(), - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - prompt=prompt_param, # type: ignore[arg-type] - ) - - kwargs = model.client.responses.create.call_args.kwargs - assert kwargs["prompt"] == prompt_param \ No newline at end of file diff --git a/src/agentex/lib/core/temporal/services/__init__.py b/src/agentex/lib/core/temporal/services/__init__.py 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 5f6c0c381..000000000 --- a/src/agentex/lib/core/temporal/services/temporal_task_service.py +++ /dev/null @@ -1,102 +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, InterruptTaskParams -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 - """ - # None / 0 / negative => no execution timeout (workflow can stay open - # indefinitely, which long-lived chat/session agents rely on). A positive - # value bounds the whole continue-as-new chain's wall-clock lifetime. - timeout_seconds = self._env_vars.WORKFLOW_EXECUTION_TIMEOUT_SECONDS - execution_timeout = ( - timedelta(seconds=timeout_seconds) - if timeout_seconds and timeout_seconds > 0 - else None - ) - return await self._temporal_client.start_workflow( - workflow=self._env_vars.WORKFLOW_NAME, - arg=CreateTaskParams( - agent=agent, - task=task, - params=params, - ), - id=task.id, - task_queue=self._env_vars.WORKFLOW_TASK_QUEUE, - execution_timeout=execution_timeout, - ) - - 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 interrupt(self, agent: Agent, task: Task, request: dict | None = None) -> None: - """Forward a task/interrupt to the running workflow as a dedicated signal. - - Non-terminal: unlike ``cancel``/``terminate`` this does NOT tear down the - workflow. It signals ``interrupt_turn`` so the workflow's ``on_interrupt`` - hook can stop the in-flight turn while leaving the task continuable. - """ - return await self._temporal_client.send_signal( - workflow_id=task.id, - signal=SignalName.INTERRUPT_TURN.value, - payload=InterruptTaskParams( - agent=agent, - task=task, - request=request, - ).model_dump(), - ) - - async def cancel(self, task_id: str) -> None: - return await self._temporal_client.cancel_workflow( - workflow_id=task_id, - ) - - async def terminate(self, task_id: str) -> None: - return await self._temporal_client.terminate_workflow( - workflow_id=task_id, - ) diff --git a/src/agentex/lib/core/temporal/types/__init__.py b/src/agentex/lib/core/temporal/types/__init__.py 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 62624832e..000000000 --- a/src/agentex/lib/core/temporal/types/workflow.py +++ /dev/null @@ -1,10 +0,0 @@ -from enum import Enum - - -class SignalName(str, Enum): - RECEIVE_EVENT = "receive_event" - # Dedicated non-terminal "stop the current turn" signal (design doc section 7). - # Routed to the overridable BaseWorkflow.on_interrupt hook. Kept separate from - # RECEIVE_EVENT so the interrupt handler can interleave with (and cancel) the - # in-flight turn WITHOUT waiting on the turn lock the running turn holds. - INTERRUPT_TURN = "interrupt_turn" diff --git a/src/agentex/lib/core/temporal/workers/__init__.py b/src/agentex/lib/core/temporal/workers/__init__.py 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 2b4958b1f..000000000 --- a/src/agentex/lib/core/temporal/workers/worker.py +++ /dev/null @@ -1,288 +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 -from agentex.lib.core.compat.version_guard import assert_backend_compatible - -logger = make_logger(__name__) - - -class DateTimeJSONEncoder(AdvancedJSONEncoder): - @override - def default(self, o: Any) -> Any: - if isinstance(o, datetime.datetime): - return o.isoformat() - return super().default(o) - - -class DateTimeJSONTypeConverter(JSONTypeConverter): - @override - def to_typed_value(self, hint: type, value: Any) -> Any | None: - if hint == datetime.datetime: - return datetime.datetime.fromisoformat(value) - return JSONTypeConverter.Unhandled - - -class DateTimePayloadConverter(CompositePayloadConverter): - def __init__(self) -> None: - json_converter = JSONPlainPayloadConverter( - encoder=DateTimeJSONEncoder, - custom_type_converters=[DateTimeJSONTypeConverter()], - ) - super().__init__( - *[ - c if not isinstance(c, JSONPlainPayloadConverter) else json_converter - for c in DefaultPayloadConverter.default_encoding_payload_converters - ] - ) - - -custom_data_converter = dataclasses.replace( - DataConverter.default, - payload_converter_class=DateTimePayloadConverter, -) - - -def _validate_plugins(plugins: list) -> None: - """Validate that all items in the plugins list are valid Temporal plugins.""" - for i, plugin in enumerate(plugins): - if not isinstance(plugin, (ClientPlugin, WorkerPlugin)): - raise TypeError( - f"Plugin at index {i} must be an instance of temporalio.client.Plugin " - f"or temporalio.worker.Plugin, got {type(plugin).__name__}" - ) - - -def _validate_interceptors(interceptors: list) -> None: - """Validate that all items in the interceptors list are valid Temporal interceptors.""" - for i, interceptor in enumerate(interceptors): - if not isinstance(interceptor, Interceptor): - raise TypeError( - f"Interceptor at index {i} must be an instance of temporalio.worker.Interceptor, " - f"got {type(interceptor).__name__}" - ) - - -async def get_temporal_client( - temporal_address: str, - metrics_url: str | None = None, - plugins: list = [], - payload_codec: PayloadCodec | None = None, - data_converter: DataConverter | None = None, -) -> 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: - # Fail fast if this worker is pointed at a backend older than the SDK supports โ€” - # the worker process never goes through the ACP server lifespan, so it needs its - # own guard (mirrors base_acp_server.lifespan_context). - await assert_backend_compatible(env_vars.AGENTEX_BASE_URL) - await register_agent(env_vars) - 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 e47fd9a5c..000000000 --- a/src/agentex/lib/core/temporal/workflows/workflow.py +++ /dev/null @@ -1,191 +0,0 @@ -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import Any, Callable -from datetime import timedelta - -from temporalio import workflow - -from agentex.protocol.acp import SendEventParams, CreateTaskParams, InterruptTaskParams -from agentex.lib.utils.logging import make_logger -from agentex.lib.core.temporal.types.workflow import SignalName - -logger = make_logger(__name__) - - -class BaseWorkflow(ABC): - def __init__( - self, - display_name: str, - ): - self.display_name = display_name - - @abstractmethod - @workflow.signal(name=SignalName.RECEIVE_EVENT) - async def on_task_event_send(self, params: SendEventParams) -> None: - raise NotImplementedError - - @abstractmethod - async def on_task_create(self, params: CreateTaskParams) -> None: - raise NotImplementedError - - # ------------------------------------------------------------------ # - # Continue-as-new lifecycle helpers # - # # - # These let a long-lived chat/session workflow recycle its event # - # history so it can stay open indefinitely without hitting Temporal's # - # ~50k-event / 50MB history limit. They are OPT-IN: an agent gets # - # recycling only by calling `run_until_complete` from its # - # `@workflow.run` instead of the usual indefinite `wait_condition`. # - # The SDK owns the hard Temporal mechanics (recycle decision and # - # draining in-flight handlers before the continue_as_new call). # - # Restoring state after a recycle is the AGENT's job and is # - # framework-specific (rebuild from `adk.messages`, an `adk.state` # - # snapshot, or a framework's own memory); that lands per-integration # - # in follow-up PRs. The 000_hello_acp example shows the minimal # - # stateless adoption that needs no restoration. # - # ------------------------------------------------------------------ # - - def should_continue_as_new(self) -> bool: - """Whether this run should recycle its event history via continue-as-new. - - True when Temporal suggests it: ``is_continue_as_new_suggested()`` fires as - the event history approaches the server's size/count limit, so we let - Temporal own the threshold rather than configuring one ourselves. - - This reads only a deterministic ``workflow.info()`` value and emits no - commands, so it is safe to use directly as a ``workflow.wait_condition`` - predicate, e.g.:: - - await workflow.wait_condition( - lambda: self._complete_task or self.should_continue_as_new() - ) - """ - return workflow.info().is_continue_as_new_suggested() - - async def drain_and_continue_as_new( - self, - *args: Any, - is_complete: Callable[[], bool] | None = None, - ) -> None: - """Drain in-flight signal handlers, then continue-as-new. - - Call this from the agent's ``@workflow.run`` once the run loop wakes for a - recycle (see :meth:`should_continue_as_new`). ``args`` are forwarded - verbatim to ``workflow.continue_as_new`` and become the new run's input, so - pass whatever your ``@workflow.run`` signature expects โ€” typically the - original ``CreateTaskParams`` (the new run keeps the same workflow id / task - id and re-hydrates its state from ``adk.state``). - - IMPORTANT: keep your data OUTSIDE workflow state BEFORE calling this โ€” - messages in ``adk.messages`` and any other state in ``adk.state``. - In-workflow attributes do NOT survive the recycle; only the forwarded - ``args`` do. - - Waits on ``all_handlers_finished`` first so an in-flight turn (a signal - handler still running an activity) is never lost or duplicated across the - recycle boundary. ``workflow.continue_as_new`` raises to end the run, so - this never returns normally โ€” EXCEPT when ``is_complete`` is given and - returns True after draining: a completion signal can arrive while we wait - for the drain, and the recycled run would start fresh (losing that - completion), so in that case we return without recycling and let the caller - finish. - """ - # Don't recycle until any signal handler still running has finished, so a - # message mid-flight at the boundary is carried into the next run intact. - await workflow.wait_condition(workflow.all_handlers_finished) - # A completion signal may have landed during the drain โ€” re-check before - # recycling so a workflow that should finish isn't kept open by the recycle. - if is_complete is not None and is_complete(): - return - logger.info( - "Recycling workflow via continue-as-new " - f"(history_length={workflow.info().get_current_history_length()}, " - f"run_id={workflow.info().run_id})" - ) - workflow.continue_as_new(*args) - - async def run_until_complete( - self, - *continue_as_new_args: Any, - is_complete: Callable[[], bool], - timeout: timedelta | None = None, - ) -> None: - """Keep the workflow open to field events, recycling history as needed. - - Drop-in replacement for the usual ``await workflow.wait_condition( - lambda: self._complete_task, timeout=None)`` at the end of an agent's - ``@workflow.run``. ``is_complete`` is a no-arg predicate (typically - ``lambda: self._complete_task``); ``continue_as_new_args`` are forwarded to - continue-as-new on recycle (typically the original ``CreateTaskParams``). - - Adopting this method IS the opt-in to recycling โ€” there is no flag. An agent - that keeps the old indefinite ``wait_condition`` never recycles. - - ``timeout`` is an optional cap on how long to wait with no progress; it - defaults to None = wait indefinitely (the usual case โ€” Temporal can keep huge - numbers of idle workflows open). The broader workflow-level lifetime cap is - the execution timeout (``WORKFLOW_EXECUTION_TIMEOUT_SECONDS``, also infinite - by default). On ``timeout`` expiry ``wait_condition`` raises - ``asyncio.TimeoutError`` like before. - - Persist anything you need across a recycle OUTSIDE workflow state first โ€” - messages in ``adk.messages``, other state in ``adk.state`` โ€” and rebuild it - at the top of ``@workflow.run``. - """ - while True: - await workflow.wait_condition( - lambda: is_complete() or self.should_continue_as_new(), - timeout=timeout, - ) - if is_complete(): - return - # Drains in-flight handlers, then continue-as-new (raises; never - # returns) โ€” UNLESS a completion signal arrived during the drain, in - # which case it returns here and the next loop iteration completes. - await self.drain_and_continue_as_new( - *continue_as_new_args, is_complete=is_complete - ) - if is_complete(): - return - - def is_continued_run(self) -> bool: - """Whether this run was produced by a continue-as-new from a prior run. - - True only on a recycled run (``workflow.info().continued_run_id`` is set), - False on the original run a client created. Use it in ``@workflow.run`` to - gate one-time prologue work that must NOT repeat on every recycle โ€” e.g. a - welcome message, or rehydrating state only when there's something to restore. - The recycled run re-enters ``@workflow.run`` from the top, so anything not - gated here runs again on each history rollover. - """ - return workflow.info().continued_run_id is not None - - @workflow.signal(name=SignalName.INTERRUPT_TURN) - async def on_interrupt(self, params: InterruptTaskParams) -> None: - """Handle a task/interrupt: stop the in-flight turn, keep the task continuable. - - This is the durable transport for the platform's ``task/interrupt`` verb - (design doc section 7). The control plane forwards ``task/interrupt`` to the - agent pod over HTTP JSON-RPC; the ACP/Temporal layer routes it to the - ``interrupt_turn`` signal, which invokes this hook. - - Temporal runs signal handlers on the same deterministic event loop as the - workflow ``run`` method, interleaving at ``await`` points. This handler runs - CONCURRENTLY with the in-flight turn coroutine, so it MUST NOT acquire the - turn lock (the running turn already holds it) โ€” doing so would deadlock: the - interrupt could never fire because it would be waiting on the very turn it is - meant to stop. - - The base implementation is a no-op so existing agents keep working (and the - signal is still accepted, avoiding "unhandled signal" warnings). Interruptible - agents (the golden agent is the reference) override this to actually stop the - in-flight model / CLI subprocess โ€” e.g. SIGINT the leased sandbox CLI by pid, - or cancel the inline model ``asyncio.Task`` โ€” while preserving partial output - and resume state so the next turn continues the conversation. - """ - logger.info( - "on_interrupt (no-op base impl) for task %s; override to make this agent " - "interruptible", - params.task.id, - ) diff --git a/src/agentex/lib/core/tracing/__init__.py b/src/agentex/lib/core/tracing/__init__.py 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 448d013e9..000000000 --- a/src/agentex/lib/core/tracing/processors/agentex_tracing_processor.py +++ /dev/null @@ -1,232 +0,0 @@ -import os -import asyncio -import weakref -from typing import TYPE_CHECKING, Any, Dict, override - -from agentex import Agentex -from agentex.types.span import Span -from agentex.lib.types.tracing import AgentexTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.lib.adk.utils._modules.client import create_async_agentex_client -from agentex.lib.core.tracing.processors.tracing_processor_interface import ( - SyncTracingProcessor, - AsyncTracingProcessor, -) - -if TYPE_CHECKING: - from agentex import AsyncAgentex - -logger = make_logger(__name__) - - -# NOTE: This is the Agentex-backend toggle (writes to the agentex `spans` -# table via the Agentex API). It is intentionally SEPARATE from the SGP/EGP -# processor's ``AGENTEX_TRACING_SKIP_SPAN_START`` so the two backends can be -# controlled independently. -_SKIP_SPAN_START_ENV = "AGENTEX_TRACING_SKIP_AGENTEX_SPAN_START" - - -def _skip_span_start_enabled() -> bool: - """Whether to skip the Agentex span-start write and persist each span only on end. - - The Agentex processor otherwise writes every span twice: a ``spans.create`` - on start (no ``end_time``/``output`` yet) and a ``spans.update`` on end. - The start row is overwritten by the end write moments later, so persisting - it doubles the per-span HTTP/DB write volume against the Agentex control - plane โ€” the load that timed out span-start activities and pressured the - Agentex Postgres connection pool under load. - - When enabled (the default), the start write is skipped and the END write - becomes a single ``spans.create`` carrying the complete span โ€” one INSERT - per span instead of an INSERT + UPDATE. (A plain ``spans.update`` on end - would 404 because the row was never created.) - - Default ON. Set ``AGENTEX_TRACING_SKIP_AGENTEX_SPAN_START`` to - ``0``/``false``/``no``/``off`` to restore the start write โ€” e.g. if you - need in-flight spans visible before they complete, or spans that never end - (process crash) to still be persisted. - """ - raw = os.environ.get(_SKIP_SPAN_START_ENV, "1").strip().lower() - return raw not in ("0", "false", "no", "off") - - -def _create_kwargs(span: Span) -> Dict[str, Any]: - """Full-span kwargs for ``spans.create`` โ€” used on start (skip disabled) and - on end (skip enabled, single-INSERT path).""" - return { - "name": span.name, - "start_time": span.start_time, - "end_time": span.end_time, - "id": span.id, - "trace_id": span.trace_id, - "parent_id": span.parent_id, - "input": span.input, - "output": span.output, - "data": span.data, - "task_id": span.task_id, - } - - -class AgentexSyncTracingProcessor(SyncTracingProcessor): - def __init__(self, config: AgentexTracingProcessorConfig): # noqa: ARG002 - self.client = Agentex() - # Capture the skip decision once at init: both halves of a span's - # lifecycle MUST agree, otherwise a start-skip + end-update lands on a - # non-existent row (404) โ€” or the reverse double-creates. Re-reading the - # env per event would let a mid-span toggle (tests, config reload) split - # the decision. Deploy-time flag, so a single read is correct. - self._skip_span_start = _skip_span_start_enabled() - logger.info( - "Agentex tracing span-start write %s (%s)", - "disabled โ€” end-only ingest" if self._skip_span_start else "enabled", - _SKIP_SPAN_START_ENV, - ) - - @override - def on_span_start(self, span: Span) -> None: - # End-only ingest: by default the start write is skipped (see - # _skip_span_start_enabled) so each span is persisted once, on end. - if self._skip_span_start: - return - self.client.spans.create(**_create_kwargs(span)) - - @override - def on_span_end(self, span: Span) -> None: - # End-only ingest: the start create was skipped, so persist the complete - # span as a single INSERT here (a bare spans.update would 404 โ€” no row). - if self._skip_span_start: - self.client.spans.create(**_create_kwargs(span)) - return - - update: Dict[str, Any] = {} - if span.trace_id: - update["trace_id"] = span.trace_id - if span.name: - update["name"] = span.name - if span.parent_id: - update["parent_id"] = span.parent_id - if span.start_time: - update["start_time"] = span.start_time.isoformat() - if span.end_time is not None: - update["end_time"] = span.end_time.isoformat() - if span.input is not None: - update["input"] = span.input - if span.output is not None: - update["output"] = span.output - if span.data is not None: - update["data"] = span.data - - self.client.spans.update( - span.id, - **span.model_dump( - mode="json", - exclude={"id"}, - exclude_defaults=True, - exclude_none=True, - exclude_unset=True, - ), - ) - - @override - def shutdown(self) -> None: - pass - - -class AgentexAsyncTracingProcessor(AsyncTracingProcessor): - def __init__(self, config: AgentexTracingProcessorConfig): # noqa: ARG002 - # Per-event-loop client cache. httpx.AsyncClient is bound to the - # loop that created it, so in sync-ACP / streaming contexts (where - # the active loop can change between requests) we keep one client - # per loop instead of disabling keepalive entirely. The cache is a - # WeakKeyDictionary so a GC'd loop and its client are evicted - # automatically โ€” using id() as a key would reuse entries when - # CPython recycles a freed loop's memory address. - self._clients_by_loop: weakref.WeakKeyDictionary[ - asyncio.AbstractEventLoop, "AsyncAgentex" - ] = weakref.WeakKeyDictionary() - # Capture the skip decision once at init: both halves of a span's - # lifecycle MUST agree, otherwise a start-skip + end-update lands on a - # non-existent row (404) โ€” or the reverse double-creates. Re-reading the - # env per event would let a mid-span toggle (tests, config reload) split - # the decision. Deploy-time flag, so a single read is correct. - self._skip_span_start = _skip_span_start_enabled() - logger.info( - "Agentex tracing span-start write %s (%s)", - "disabled โ€” end-only ingest" if self._skip_span_start else "enabled", - _SKIP_SPAN_START_ENV, - ) - - def _build_client(self) -> "AsyncAgentex": - import httpx - - # Keepalive ON: connections are reused within a single event loop, - # eliminating the TLS-handshake-per-span penalty under load. - return create_async_agentex_client( - http_client=httpx.AsyncClient( - limits=httpx.Limits(max_keepalive_connections=20), - ), - ) - - @property - def client(self) -> "AsyncAgentex": - try: - loop = asyncio.get_running_loop() - except RuntimeError: - return self._build_client() - client = self._clients_by_loop.get(loop) - if client is None: - client = self._build_client() - self._clients_by_loop[loop] = client - return client - - # TODO(AGX1-199): Add batch create/update endpoints to Agentex API and use - # them here instead of one HTTP call per span. - # https://linear.app/scale-epd/issue/AGX1-199/add-agentex-batch-endpoint-for-traces - @override - async def on_span_start(self, span: Span) -> None: - # End-only ingest: by default the start write is skipped (see - # _skip_span_start_enabled) so each span is persisted once, on end. - if self._skip_span_start: - return - await self.client.spans.create(**_create_kwargs(span)) - - @override - async def on_span_end(self, span: Span) -> None: - # End-only ingest: the start create was skipped, so persist the complete - # span as a single INSERT here (a bare spans.update would 404 โ€” no row). - if self._skip_span_start: - await self.client.spans.create(**_create_kwargs(span)) - return - - update: Dict[str, Any] = {} - if span.trace_id: - update["trace_id"] = span.trace_id - if span.name: - update["name"] = span.name - if span.parent_id: - update["parent_id"] = span.parent_id - if span.start_time: - update["start_time"] = span.start_time.isoformat() - if span.end_time: - update["end_time"] = span.end_time.isoformat() - if span.input: - update["input"] = span.input - if span.output: - update["output"] = span.output - if span.data: - update["data"] = span.data - - await self.client.spans.update( - span.id, - **span.model_dump( - mode="json", - exclude={"id"}, - exclude_defaults=True, - exclude_none=True, - exclude_unset=True, - ), - ) - - @override - async def shutdown(self) -> None: - pass diff --git a/src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py b/src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py deleted file mode 100644 index 6d186de5f..000000000 --- a/src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py +++ /dev/null @@ -1,233 +0,0 @@ -from __future__ import annotations - -import os -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.span_error import get_span_error -from agentex.lib.core.tracing.processors.tracing_processor_interface import ( - SyncTracingProcessor, - AsyncTracingProcessor, -) - -logger = make_logger(__name__) - - -_SKIP_SPAN_START_ENV = "AGENTEX_TRACING_SKIP_SPAN_START" - - -def _skip_span_start_enabled() -> bool: - """Whether to skip the span-start upsert and write each span only on end. - - Tracing writes each span twice โ€” once on start (no ``end_time``) and once - on end. The start row is only ever overwritten by the end write moments - later, so persisting it doubles span-ingest write volume and, on the SGP - backend, costs a non-HOT UPDATE (tsvector/GIN recompute + index churn) plus - a dead tuple per span. Skipping the start makes the end write a single - INSERT. - - Default ON. Set ``AGENTEX_TRACING_SKIP_SPAN_START`` to - ``0``/``false``/``no``/``off`` to restore the start write โ€” e.g. if you - need in-flight spans visible before they complete, or spans that never end - (process crash) to still be persisted. - """ - raw = os.environ.get(_SKIP_SPAN_START_ENV, "1").strip().lower() - return raw not in ("0", "false", "no", "off") - - -def _get_span_type(span: Span) -> str: - """Read span_type from span.data['__span_type__'], defaulting to STANDALONE.""" - if isinstance(span.data, dict): - value = span.data.get("__span_type__", "STANDALONE") - return str(value) - return "STANDALONE" - - -def _add_source_to_span(span: Span, env_vars: EnvironmentVariables) -> None: - if span.data is None: - span.data = {} - if isinstance(span.data, dict): - span.data["__source__"] = "agentex" - if env_vars.ACP_TYPE is not None: - span.data["__acp_type__"] = env_vars.ACP_TYPE - if env_vars.AGENT_NAME is not None: - span.data["__agent_name__"] = env_vars.AGENT_NAME - if env_vars.AGENT_ID is not None: - span.data["__agent_id__"] = env_vars.AGENT_ID - - -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] - error = get_span_error(span) - if error is not None: - sgp_span.set_error(error_type=error["type"], error_message=error["message"]) - return sgp_span - - -class SGPSyncTracingProcessor(SyncTracingProcessor): - def __init__(self, config: SGPTracingProcessorConfig): - disabled = config.sgp_api_key == "" or config.sgp_account_id == "" - tracing.init( - SGPClient( - api_key=config.sgp_api_key, - account_id=config.sgp_account_id, - base_url=config.sgp_base_url, - ), - disabled=disabled, - ) - self.env_vars = EnvironmentVariables.refresh() - logger.info( - "SGP tracing span-start upsert %s (%s)", - "disabled โ€” end-only ingest" if _skip_span_start_enabled() else "enabled", - _SKIP_SPAN_START_ENV, - ) - - @override - def on_span_start(self, span: Span) -> None: - # End-only ingest: by default the start write is skipped (see - # _skip_span_start_enabled) so each span is persisted once, on end. - if _skip_span_start_enabled(): - return - sgp_span = _build_sgp_span(span, self.env_vars) - sgp_span.flush(blocking=False) - - @override - def on_span_end(self, span: Span) -> None: - sgp_span = _build_sgp_span(span, self.env_vars) - sgp_span.end_time = span.end_time.isoformat() # type: ignore[union-attr] - sgp_span.flush(blocking=False) - - @override - def shutdown(self) -> None: - flush_queue() - - -class SGPAsyncTracingProcessor(AsyncTracingProcessor): - def __init__(self, config: SGPTracingProcessorConfig): - self.disabled = config.sgp_api_key == "" or config.sgp_account_id == "" - self._config = config - # Per-event-loop client cache. httpx.AsyncClient ties its connection - # pool to the loop it was created on; in sync-ACP / streaming contexts - # the active loop can change between requests. Caching per loop lets - # us keep keepalive on within each loop while staying safe across - # loops. The cache is a WeakKeyDictionary so a GC'd loop and its - # client are evicted automatically โ€” using id() as a key would reuse - # entries when CPython recycles a freed loop's memory address. - self._clients_by_loop: weakref.WeakKeyDictionary[ - asyncio.AbstractEventLoop, AsyncSGPClient - ] = weakref.WeakKeyDictionary() - self.env_vars = EnvironmentVariables.refresh() - logger.info( - "SGP tracing span-start upsert %s (%s)", - "disabled โ€” end-only ingest" if _skip_span_start_enabled() else "enabled", - _SKIP_SPAN_START_ENV, - ) - - def _build_client(self) -> AsyncSGPClient: - import httpx - - return AsyncSGPClient( - api_key=self._config.sgp_api_key, - account_id=self._config.sgp_account_id, - base_url=self._config.sgp_base_url, - # Keepalive ON: connections are reused within a single event loop, - # which removes the TLS-handshake-per-span penalty observed under - # load. Cross-loop safety is preserved by the per-loop cache. - http_client=httpx.AsyncClient( - limits=httpx.Limits(max_keepalive_connections=20), - ), - ) - - def _get_client(self) -> AsyncSGPClient | None: - """Return the AsyncSGPClient bound to the current event loop, creating - one on first use. Returns None when the processor is disabled.""" - if self.disabled: - return None - try: - loop = asyncio.get_running_loop() - except RuntimeError: - # Called from outside an event loop โ€” should not happen on the - # hot path, but build a one-off client rather than crashing. - return self._build_client() - client = self._clients_by_loop.get(loop) - if client is None: - client = self._build_client() - self._clients_by_loop[loop] = client - return client - - @override - async def on_span_start(self, span: Span) -> None: - await self.on_spans_start([span]) - - @override - async def on_span_end(self, span: Span) -> None: - await self.on_spans_end([span]) - - @override - async def on_spans_start(self, spans: list[Span]) -> None: - # End-only ingest: by default the start write is skipped (see - # _skip_span_start_enabled) so each span is persisted once, on end. - if _skip_span_start_enabled(): - return - if not spans: - return - - client = self._get_client() - if client is None: - logger.warning("SGP is disabled, skipping span upsert") - return - - sgp_spans = [_build_sgp_span(span, self.env_vars) for span in spans] - await client.spans.upsert_batch(items=[s.to_request_params() for s in sgp_spans]) - _metrics.record_export_success( - event_type="start", span_count=len(spans), processor="sgp" - ) - - @override - async def on_spans_end(self, spans: list[Span]) -> None: - if not spans: - return - - client = self._get_client() - if client is None: - return - - sgp_spans: list[SGPSpan] = [] - for span in spans: - sgp_span = _build_sgp_span(span, self.env_vars) - sgp_span.end_time = span.end_time.isoformat() # type: ignore[union-attr] - sgp_spans.append(sgp_span) - await client.spans.upsert_batch(items=[s.to_request_params() for s in sgp_spans]) - _metrics.record_export_success( - event_type="end", span_count=len(spans), processor="sgp" - ) - - @override - async def shutdown(self) -> None: - pass diff --git a/src/agentex/lib/core/tracing/processors/tracing_processor_interface.py b/src/agentex/lib/core/tracing/processors/tracing_processor_interface.py 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_error.py b/src/agentex/lib/core/tracing/span_error.py deleted file mode 100644 index 508c5e800..000000000 --- a/src/agentex/lib/core/tracing/span_error.py +++ /dev/null @@ -1,36 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from agentex.types.span import Span - -# Reserved key under ``Span.data`` carrying failure info for a span whose -# context-manager body raised. Mirrors the existing ``__span_type__`` / -# ``__source__`` reserved-key convention already read/written by the SGP -# processor. Stored in ``data`` because the Span model is generated from the -# OpenAPI spec and has no first-class status/error field; ``data`` is a real -# field, so it survives ``model_copy(deep=True)`` and round-trips to both the -# SGP and agentex-native span stores. -SPAN_ERROR_KEY = "__error__" - - -def set_span_error(span: Span, exc: BaseException) -> None: - """Record an exception on ``span`` under ``data[SPAN_ERROR_KEY]``. - - No-op when ``span.data`` is a list (matching ``_add_source_to_span``, which - only attaches metadata to dict-shaped data). - """ - error = {"type": type(exc).__name__, "message": str(exc)} - if span.data is None: - span.data = {} - if isinstance(span.data, dict): - span.data[SPAN_ERROR_KEY] = error - - -def get_span_error(span: Span) -> dict[str, Any] | None: - """Return the error recorded by :func:`set_span_error`, or ``None``.""" - if isinstance(span.data, dict): - value = span.data.get(SPAN_ERROR_KEY) - if isinstance(value, dict): - return value - return None diff --git a/src/agentex/lib/core/tracing/span_queue.py b/src/agentex/lib/core/tracing/span_queue.py deleted file mode 100644 index d6ff7c1f6..000000000 --- a/src/agentex/lib/core/tracing/span_queue.py +++ /dev/null @@ -1,477 +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__) - -# Max spans coalesced into one ``upsert_batch`` HTTP call (one -# ``INSERT ... ON CONFLICT`` statement server-side). Larger batches amortize -# the per-request round trip and the per-statement parse/plan + index -# maintenance overhead, which dominates at high span volume. Kept well under -# the EGP backend's 1000-row cap; tune per-deploy via -# ``AGENTEX_SPAN_QUEUE_BATCH_SIZE``. -_DEFAULT_BATCH_SIZE = 200 -# Max time the drain lingers after the first span to let a batch fill. Spans -# typically arrive a few ms apart, so a longer linger fills the larger batch -# above rather than shipping near-size-1 batches; bounded so worst-case ingest -# latency (and the in-flight loss window) stays sub-second. -_DEFAULT_LINGER_MS = 250 -# 0 == unbounded (preserves prior behavior). A bound makes backpressure -# visible (dropped spans are counted) and caps worst-case memory. -_DEFAULT_MAX_SIZE = 0 -# Total attempts per batch for a *transient* failure (1 == no retry). -_DEFAULT_MAX_RETRIES = 1 -# Max number of batch-export HTTP requests in flight at once. The export -# backend (EGP) processes each upsert_batch in ~150ms but serves many requests -# concurrently; issuing one batch at a time caps per-pod egress at ~1/latency. -# Sending several concurrently lets a pod keep up with span production under -# load. ``1`` restores the old strictly-serial behavior. -_DEFAULT_CONCURRENCY = 3 -# HTTP statuses worth retrying at the queue level. These are explicit -# backpressure / transient signals; everything else (esp. 401/403/4xx auth and -# validation errors) is a permanent failure that re-enqueuing cannot fix. Note -# the underlying SGP client already retries these internally, so queue-level -# retry only helps when its budget is exhausted by a longer blip. -_RETRYABLE_STATUS_CODES = frozenset({429, 500, 502, 503, 504}) - - -def _read_int_env(name: str, default: int, *, minimum: int = 0) -> int: - """Read a non-negative int from the environment, clamping to ``minimum`` - and falling back to ``default`` when unset or unparseable.""" - raw = os.environ.get(name) - if raw is None: - return default - try: - return max(minimum, int(raw)) - except ValueError: - logger.warning("Ignoring invalid %s=%r; using default %d", name, raw, default) - return default - - -def _read_linger_ms_env() -> int: - """Read AGENTEX_SPAN_QUEUE_LINGER_MS from the environment, falling back to - _DEFAULT_LINGER_MS when unset or unparseable. Negative values are clamped - to 0 (i.e. "drain immediately, no linger").""" - return _read_int_env("AGENTEX_SPAN_QUEUE_LINGER_MS", _DEFAULT_LINGER_MS) - - -def _is_retryable_exc(exc: BaseException) -> bool: - """A failure is retryable only when it carries an HTTP ``status_code`` in - the retryable set. Connection/timeout errors (no status_code) have already - been retried by the SGP client, and bare exceptions (programming bugs) must - never be retried โ€” re-enqueuing them would spin forever.""" - status_code = getattr(exc, "status_code", None) - return isinstance(status_code, int) and status_code in _RETRYABLE_STATUS_CODES - - -class SpanEventType(str, Enum): - START = "start" - END = "end" - - -@dataclass -class _SpanQueueItem: - event_type: SpanEventType - span: Span - processors: list[AsyncTracingProcessor] - enqueued_at: float | None = None - # Number of times this item has already been dispatched. Used to bound - # re-enqueue on transient failures. - attempts: int = 0 - - -class AsyncSpanQueue: - """Background FIFO queue for async span processing. - - Span events are enqueued synchronously (non-blocking) and drained by a - background task. The drain coalesces ready events into batches and - *dispatches* each batch's export as its own task, so up to ``concurrency`` - batch requests can be in flight at once. This matters because each - ``upsert_batch`` HTTP call takes tens-to-hundreds of ms server-side; issuing - them one at a time caps a pod's egress at ~1/latency and lets a backlog - build under load. - - Ordering guarantee: a span's START export always completes before its END - export is issued. END batches wait on the START batches that were in flight - when they were formed; because a span's START is always enqueued before its - END, that span's START send is either still in flight (and waited on) or - already finished. Independent spans export fully concurrently. - - Once the drain loop picks up the first item, it lingers up to ``linger_ms`` - waiting for more items to coalesce into the same batch. Without the linger - the drain almost always returned size-1 batches under real agent workloads, - because spans typically arrive a few ms apart. - - Reliability: - - ``max_size`` bounds the queue. When full, new events are dropped and - counted (see ``dropped_spans``) rather than growing memory without limit. - ``0`` keeps the queue unbounded. - - A batch that fails with a *transient* HTTP status (429/5xx) is - re-enqueued up to ``max_retries`` total attempts. Permanent failures - (auth/validation/bugs) are dropped and counted immediately. - """ - - def __init__( - self, - batch_size: int | None = None, - linger_ms: int | None = None, - max_size: int | None = None, - max_retries: int | None = None, - concurrency: int | None = None, - ) -> None: - resolved_max_size = ( - _read_int_env("AGENTEX_SPAN_QUEUE_MAX_SIZE", _DEFAULT_MAX_SIZE) if max_size is None else max(0, max_size) - ) - self._queue: asyncio.Queue[_SpanQueueItem] = asyncio.Queue(maxsize=resolved_max_size) - self._drain_task: asyncio.Task[None] | None = None - self._stopping = False - self._batch_size = ( - _read_int_env("AGENTEX_SPAN_QUEUE_BATCH_SIZE", _DEFAULT_BATCH_SIZE, minimum=1) - if batch_size is None - else max(1, batch_size) - ) - self._linger_ms = _read_linger_ms_env() if linger_ms is None else max(0, linger_ms) - self._max_retries = ( - _read_int_env("AGENTEX_SPAN_QUEUE_MAX_RETRIES", _DEFAULT_MAX_RETRIES, minimum=1) - if max_retries is None - else max(1, max_retries) - ) - self._concurrency = ( - _read_int_env("AGENTEX_SPAN_QUEUE_CONCURRENCY", _DEFAULT_CONCURRENCY, minimum=1) - if concurrency is None - else max(1, concurrency) - ) - # Bounds concurrent export HTTP requests. - self._send_sema = asyncio.Semaphore(self._concurrency) - # Outstanding dispatched send tasks, and the subset that are START - # sends (END sends wait on these to preserve per-span ordering). - self._inflight: set[asyncio.Task[None]] = set() - self._inflight_starts: set[asyncio.Task[None]] = set() - # Total spans dropped for any reason (full queue, shutdown, permanent - # failure, exhausted retries). Surfaced for metrics/observability so - # span loss stops being silent. - self._dropped_spans = 0 - - @property - def dropped_spans(self) -> int: - """Cumulative count of spans dropped (never delivered).""" - return self._dropped_spans - - @property - def depth(self) -> int: - """Current number of items waiting in the queue.""" - return self._queue.qsize() - - def _record_drop(self, count: int, reason: str) -> None: - if count <= 0: - return - self._dropped_spans += count - if "shutting down" in reason: - _metrics.record_span_dropped("shutdown", count) - elif "queue full" in reason: - _metrics.record_span_dropped("queue_full", count) - # Warn on the first drop and then sparsely, so a drop storm is visible - # without flooding the log. - if self._dropped_spans == count or self._dropped_spans % 100 < count: - logger.warning( - "Span queue dropped %d span(s) (%s); %d dropped in total", - count, - reason, - self._dropped_spans, - ) - - def enqueue( - self, - event_type: SpanEventType, - span: Span, - processors: list[AsyncTracingProcessor], - ) -> None: - if self._stopping: - self._record_drop(1, "queue shutting down") - return - self._ensure_drain_running() - try: - self._queue.put_nowait( - _SpanQueueItem( - event_type=event_type, - span=span, - processors=processors, - enqueued_at=_metrics.monotonic_if_enabled(), - ) - ) - _metrics.record_span_enqueued(event_type.value) - except asyncio.QueueFull: - self._record_drop(1, "queue full") - - def _ensure_drain_running(self) -> None: - if self._drain_task is None or self._drain_task.done(): - self._drain_task = asyncio.create_task(self._drain_loop()) - - # ------------------------------------------------------------------ - # Drain loop - # ------------------------------------------------------------------ - - async def _drain_loop(self) -> None: - while True: - # Backpressure: cap the number of in-flight send tasks so the drain - # does not run unboundedly ahead of the exporters. - while len(self._inflight) >= self._concurrency: - await asyncio.wait(set(self._inflight), return_when=asyncio.FIRST_COMPLETED) - - # Block until at least one item is available. - first = await self._queue.get() - batch: list[_SpanQueueItem] = [first] - - # Linger briefly so spans emitted within the window coalesce into - # one batch. Stop early when the batch fills, when the linger - # window elapses, or as soon as the queue is briefly empty *after* - # the deadline. - if self._linger_ms > 0 and not self._stopping: - loop = asyncio.get_running_loop() - deadline = loop.time() + (self._linger_ms / 1000.0) - while len(batch) < self._batch_size: - remaining = deadline - loop.time() - if remaining <= 0: - break - try: - batch.append(await asyncio.wait_for(self._queue.get(), timeout=remaining)) - except asyncio.TimeoutError: - break - else: - # No linger โ€” drain whatever is already queued and stop. - while len(batch) < self._batch_size: - try: - batch.append(self._queue.get_nowait()) - except asyncio.QueueEmpty: - break - - _metrics.record_batch_coalesced( - queue_depth=self._queue.qsize() + len(batch), - batch_items=batch, - ) - - # Separate START and END events and dispatch each as its own send - # task. Dispatching STARTs first (so they are registered before the - # END snapshot) guarantees an END never outruns a START of the same - # span whose events land in this batch. - starts = [i for i in batch if i.event_type == SpanEventType.START] - ends = [i for i in batch if i.event_type == SpanEventType.END] - if starts: - self._dispatch(starts, SpanEventType.START) - if ends: - # Re-check backpressure before the second dispatch so a batch - # carrying both event types can't push _inflight past the cap. - while len(self._inflight) >= self._concurrency: - await asyncio.wait(set(self._inflight), return_when=asyncio.FIRST_COMPLETED) - self._dispatch(ends, SpanEventType.END) - - def _dispatch(self, items: list[_SpanQueueItem], event_type: SpanEventType) -> None: - """Spawn a background task to export ``items``. - - END sends snapshot the currently in-flight START tasks and wait for them - before issuing, preserving the per-span START-before-END invariant. - """ - barrier = tuple(self._inflight_starts) if event_type == SpanEventType.END else () - task = asyncio.create_task(self._run_send(items, barrier)) - self._inflight.add(task) - task.add_done_callback(self._inflight.discard) - if event_type == SpanEventType.START: - self._inflight_starts.add(task) - task.add_done_callback(self._inflight_starts.discard) - - async def _run_send(self, items: list[_SpanQueueItem], barrier: tuple[asyncio.Task[None], ...]) -> None: - try: - if barrier: - # Wait for the START sends this END batch depends on. Their - # exceptions are irrelevant here โ€” we only need them finished. - await asyncio.gather(*barrier, return_exceptions=True) - phase_start = time.perf_counter() - await self._process_items(items) - if items: - _metrics.record_batch_phase( - phase=items[0].event_type.value, - size=len(items), - duration_ms=(time.perf_counter() - phase_start) * 1000.0, - ) - finally: - # Mark every item done so shutdown's queue.join() can complete only - # once all sends (and their retries) have finished. - for _ in items: - self._queue.task_done() - - async def _process_items(self, items: list[_SpanQueueItem]) -> None: - """Dispatch a batch of same-event-type items to each processor in one call. - - Groups spans by processor so each processor sees its full slice of the - drain batch at once. Processors that override the batched methods can - then send a single HTTP request per drain cycle instead of N. - """ - if not items: - return - - event_type = items[0].event_type - assert all(i.event_type == event_type for i in items), ( - "_process_items requires all items to share the same event_type; " - "callers must split START and END batches before dispatching." - ) - by_processor: dict[AsyncTracingProcessor, list[_SpanQueueItem]] = {} - for item in items: - for p in item.processors: - by_processor.setdefault(p, []).append(item) - - await asyncio.gather(*[self._handle(p, batch, event_type) for p, batch in by_processor.items()]) - - async def _handle( - self, - p: AsyncTracingProcessor, - items: list[_SpanQueueItem], - event_type: SpanEventType, - ) -> None: - spans = [item.span for item in items] - try: - # Hold a concurrency slot only for the duration of the HTTP call. - async with self._send_sema: - if event_type == SpanEventType.START: - await p.on_spans_start(spans) - else: - await p.on_spans_end(spans) - except Exception as exc: - self._handle_failure(p, items, event_type, exc) - - def _handle_failure( - self, - p: AsyncTracingProcessor, - items: list[_SpanQueueItem], - event_type: SpanEventType, - exc: Exception, - ) -> None: - # Re-enqueue transient failures, drop everything else. Re-enqueue is - # bounded by max_retries, so even during shutdown the queue's join() - # still terminates after a finite number of passes. - if _is_retryable_exc(exc): - retriable = [item for item in items if item.attempts + 1 < self._max_retries] - exhausted = len(items) - len(retriable) - if exhausted: - self._record_drop(exhausted, f"{type(p).__name__} retries exhausted during {event_type.value}") - _metrics.record_export_failure( - processor=p, - event_type=event_type.value, - span_count=exhausted, - exc=exc, - ) - for item in retriable: - self._reenqueue(item, p) - if retriable: - logger.warning( - "Tracing processor %s failed handling %d spans during %s (%s); re-enqueued %d for retry", - type(p).__name__, - len(items), - event_type.value, - type(exc).__name__, - len(retriable), - ) - return - - self._record_drop(len(items), f"{type(p).__name__} permanent failure during {event_type.value}") - logger.exception( - "Tracing processor %s failed handling %d spans during %s", - type(p).__name__, - len(items), - event_type.value, - ) - _metrics.record_export_failure( - processor=p, - event_type=event_type.value, - span_count=len(items), - exc=exc, - ) - - def _reenqueue(self, item: _SpanQueueItem, p: AsyncTracingProcessor) -> None: - """Put a single failed item back on the queue, scoped to the processor - that failed, with an incremented attempt count. - - NOTE: a re-enqueued START goes to the *back* of the queue. If an END - for the same span is dispatched concurrently before this START is picked - up again, the END's barrier snapshot won't contain it, breaking the - START-before-END guarantee for that span. This is benign at the default - ``max_retries=1`` (retries disabled) but must be addressed before - enabling retries by default.""" - try: - self._queue.put_nowait( - _SpanQueueItem( - event_type=item.event_type, - span=item.span, - processors=[p], - enqueued_at=item.enqueued_at, - attempts=item.attempts + 1, - ) - ) - except asyncio.QueueFull: - self._record_drop(1, "queue full on retry") - - # ------------------------------------------------------------------ - # Shutdown - # ------------------------------------------------------------------ - - async def shutdown(self, timeout: float = 30.0) -> None: - self._stopping = True - drain_idle = self._drain_task is None or self._drain_task.done() - if self._queue.empty() and drain_idle and not self._inflight: - return - - timed_out = False - try: - # join() returns once every enqueued (and re-enqueued) item has been - # marked done by its send task. - await asyncio.wait_for(self._queue.join(), timeout=timeout) - except asyncio.TimeoutError: - timed_out = True - remaining = self._queue.qsize() - logger.warning( - "Span queue shutdown timed out after %.1fs with %d items remaining", timeout, remaining - ) - _metrics.record_shutdown_timeout(remaining_items=remaining) - - if self._drain_task is not None and not self._drain_task.done(): - self._drain_task.cancel() - try: - await self._drain_task - except asyncio.CancelledError: - pass - - # Clean up any in-flight send tasks. On a clean shutdown these are - # already finishing; on timeout, cancel the stragglers so we don't hang. - inflight = list(self._inflight) - if inflight: - if timed_out: - for task in inflight: - task.cancel() - await asyncio.gather(*inflight, return_exceptions=True) - - -_default_span_queue: AsyncSpanQueue | None = None - - -def get_default_span_queue() -> AsyncSpanQueue: - global _default_span_queue - if _default_span_queue is None: - _default_span_queue = AsyncSpanQueue() - return _default_span_queue - - -async def shutdown_default_span_queue(timeout: float = 30.0) -> None: - global _default_span_queue - if _default_span_queue is not None: - await _default_span_queue.shutdown(timeout=timeout) - _default_span_queue = None diff --git a/src/agentex/lib/core/tracing/trace.py b/src/agentex/lib/core/tracing/trace.py deleted file mode 100644 index a22bfd658..000000000 --- a/src/agentex/lib/core/tracing/trace.py +++ /dev/null @@ -1,332 +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_error import set_span_error -from agentex.lib.core.tracing.span_queue import ( - SpanEventType, - AsyncSpanQueue, - get_default_span_queue, -) -from agentex.lib.core.tracing.processors.tracing_processor_interface import ( - SyncTracingProcessor, - AsyncTracingProcessor, -) - -logger = make_logger(__name__) - - -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 - except Exception as exc: - set_span_error(span, exc) - raise - finally: - self.end_span(span) - - -class AsyncTrace: - """ - AsyncTrace is a wrapper around the Agentex API for tracing. - It provides a context manager for spans and a way to start and end spans. - It also provides a way to get spans by ID and list all spans in a trace. - """ - - def __init__( - self, - processors: list[AsyncTracingProcessor], - client: AsyncAgentex, - trace_id: str | None = None, - span_queue: AsyncSpanQueue | None = None, - ): - """ - Initialize a new trace with the specified trace ID. - - Args: - trace_id: Required trace ID to use for this trace. - processors: Optional list of tracing processors to use for this trace. - span_queue: Optional span queue for background processing. - """ - self.processors = processors - self.client = client - self.trace_id = trace_id - self._span_queue = span_queue or get_default_span_queue() - - 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 - except Exception as exc: - set_span_error(span, exc) - raise - finally: - await self.end_span(span) diff --git a/src/agentex/lib/core/tracing/tracer.py b/src/agentex/lib/core/tracing/tracer.py 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 3113b78f4..000000000 --- a/src/agentex/lib/environment_variables.py +++ /dev/null @@ -1,128 +0,0 @@ - -from __future__ import annotations - -import os -from enum import Enum -from pathlib import Path - -from dotenv import load_dotenv - -from agentex.lib.utils.logging import make_logger -from agentex.lib.utils.model_utils import BaseModel - -PROJECT_ROOT = Path(__file__).resolve().parents[2] - -logger = make_logger(__name__) - - -class EnvVarKeys(str, Enum): - ENVIRONMENT = "ENVIRONMENT" - TEMPORAL_ADDRESS = "TEMPORAL_ADDRESS" - REDIS_URL = "REDIS_URL" - AGENTEX_BASE_URL = "AGENTEX_BASE_URL" - # Agent Identifiers - AGENT_NAME = "AGENT_NAME" - AGENT_DESCRIPTION = "AGENT_DESCRIPTION" - AGENT_ID = "AGENT_ID" - AGENT_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 wall-clock time (in seconds) a workflow execution can run, - # INCLUDING retries and the entire continue-as-new chain (Temporal does not - # reset it on continue-as-new). Defaults to None = no execution timeout, so - # long-lived chat/session workflows can stay open indefinitely. None / 0 / - # negative are all treated as "no timeout" at the start_workflow call site. - # To bound idle workflows, use an explicit durable timer inside the workflow - # (e.g. run_until_complete's `timeout`), not this chain-wide ceiling. - WORKFLOW_EXECUTION_TIMEOUT_SECONDS: int | None = None - # Temporal Worker Configuration - HEALTH_CHECK_PORT: int = 80 - # Auth Configuration - AUTH_PRINCIPAL_B64: str | None = None - # 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 c259375a4..000000000 --- a/src/agentex/lib/sdk/config/agent_config.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Back-compat shim. The canonical location is :mod:`agentex.config.agent_config`. - -Kept here so existing ``from agentex.lib.sdk.config.agent_config import ...`` -imports continue to work. New code should import from the canonical path. -""" - -from agentex.config.agent_config import AgentConfig # noqa: F401 diff --git a/src/agentex/lib/sdk/config/agent_manifest.py b/src/agentex/lib/sdk/config/agent_manifest.py deleted file mode 100644 index fd743e635..000000000 --- a/src/agentex/lib/sdk/config/agent_manifest.py +++ /dev/null @@ -1,216 +0,0 @@ -"""Back-compat shim, manifest loader, and Docker build-context machinery. - -The :class:`AgentManifest` model's canonical location is -:mod:`agentex.config.agent_manifest`; it is re-exported here so existing -``from agentex.lib.sdk.config.agent_manifest import AgentManifest`` imports keep -working. The yaml loader (`load_agent_manifest`) and build machinery -(`build_context_manager`, `BuildContextManager`) stay here (CLI/build-side) so -the promoted model remains slim-safe. -""" - -from __future__ import annotations - -import io -import time -import shutil -import tarfile -import tempfile -import subprocess -from typing import IO, Any -from pathlib import Path -from contextlib import contextmanager -from collections.abc import Iterator - -from agentex.lib.utils.io import load_yaml_file -from agentex.lib.utils.logging import make_logger -from agentex.config.agent_manifest import AgentManifest # noqa: F401 - -logger = make_logger(__name__) - - -def load_agent_manifest(file_path: str) -> AgentManifest: - """Load and validate a manifest.yaml file into an AgentManifest.""" - return AgentManifest.model_validate(load_yaml_file(file_path=file_path)) - - -def build_context_manager(agent_manifest: AgentManifest, build_context_root: Path) -> BuildContextManager: - """Create a build context manager for the given manifest.""" - return BuildContextManager(agent_manifest=agent_manifest, build_context_root=build_context_root) - - -class BuildContextManager: - """ - A gateway used to manage the build context for a docker image - """ - - def __init__(self, agent_manifest: AgentManifest, build_context_root: Path): - self.agent_manifest = agent_manifest - self.build_context_root = build_context_root - self._temp_dir: tempfile.TemporaryDirectory | None = None - - self.path: Path | None = None - self.dockerfile_path = "Dockerfile" - self.dockerignore_path = ".dockerignore" - self.directory_paths: list[Path] = [] - - def __enter__(self) -> BuildContextManager: - self._temp_dir = tempfile.TemporaryDirectory() - self.path = Path(self._temp_dir.name) - - dockerfile_path = ( - self.build_context_root / self.agent_manifest.build.context.dockerfile - ) - self.add_dockerfile(root_path=self.path, dockerfile_path=dockerfile_path) - - ignore_patterns = [] - if self.agent_manifest.build.context.dockerignore: - dockerignore_path = ( - self.build_context_root / self.agent_manifest.build.context.dockerignore - ) - if dockerignore_path.exists(): - self.add_dockerignore( - root_path=self.path, dockerignore_path=dockerignore_path - ) - ignore_patterns = _extract_dockerignore_patterns(dockerignore_path) - else: - logger.warning( - f"Dockerignore file not found at {dockerignore_path}, skipping." - ) - - for directory in self.agent_manifest.build.context.include_paths: - directory_path = self.build_context_root / directory - self.add_directory( - root_path=self.path, - directory_path=directory_path, - context_root=self.build_context_root, - ignore_patterns=ignore_patterns, - ) - - return self - - def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: - if self._temp_dir: - self._temp_dir.cleanup() - - def add_dockerfile(self, root_path: Path, dockerfile_path: Path) -> None: - """ - Copies a dockerfile to the temporary context directory root - """ - shutil.copy2(dockerfile_path, root_path / self.dockerfile_path) - - def add_dockerignore(self, root_path: Path, dockerignore_path: Path) -> None: - """ - Copies a dockerignore to the temporary context directory root - """ - shutil.copy2(str(dockerignore_path), root_path / self.dockerignore_path) - - def add_directory( - self, - root_path: Path, - directory_path: Path, - context_root: Path, - ignore_patterns: list[str] | None = None, - ) -> None: - """ - Copies a directory to the temporary context directory root while maintaining its relative - path to the context root. - """ - directory_copy_start_time = time.time() - last_log_time = directory_copy_start_time - - def copy_function_with_progress(src, dst): - nonlocal directory_copy_start_time - nonlocal last_log_time - logger.info(f"Adding {src} to build context...") - shutil.copy2(src, dst) - current_time = time.time() - time_elapsed = current_time - directory_copy_start_time - - if time_elapsed > 1 and current_time - last_log_time >= 1: - logger.info( - f"Time elapsed copying ({directory_path}): {time_elapsed} " - f"seconds" - ) - last_log_time = current_time - if time_elapsed > 5: - logger.warning( - f"This may take a while... " - f"Consider adding {directory_path} or {src} to your .dockerignore file." - ) - - directory_path_relative_to_root = directory_path.relative_to(context_root) - all_ignore_patterns = [f"{root_path}*"] - if ignore_patterns: - all_ignore_patterns += ignore_patterns - shutil.copytree( - src=directory_path, - dst=root_path / directory_path_relative_to_root, - ignore=shutil.ignore_patterns(*all_ignore_patterns), - dirs_exist_ok=True, - copy_function=copy_function_with_progress, - ) - self.directory_paths.append(directory_path_relative_to_root) - - @contextmanager - def zip_stream(self, root_path: Path | None = None) -> Iterator[IO[bytes]]: - """ - Creates a tar archive of the temporary context directory - and returns a stream of the archive. - """ - if not root_path: - raise ValueError("root_path must be provided") - context = str(root_path.absolute()) - folders_to_include = "." - tar_command = ["tar", "-C", context, "-cf", "-"] - tar_command.extend(folders_to_include) - - logger.info(f"Creating archive: {' '.join(tar_command)}") - - with subprocess.Popen( - tar_command, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - ) as proc: - assert proc.stdout is not None - try: - yield proc.stdout - finally: - pass - - @staticmethod - @contextmanager - def zipped(root_path: Path | None = None) -> Iterator[IO[bytes]]: - """ - Creates a tar.gz archive of the temporary context directory - and returns a stream of the archive. - """ - if not root_path: - raise ValueError("root_path must be provided") - - tar_buffer = io.BytesIO() - - 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 eb7c863f1..000000000 --- a/src/agentex/lib/sdk/config/build_config.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Back-compat shim. The canonical location is :mod:`agentex.config.build_config`. - -Kept here so existing ``from agentex.lib.sdk.config.build_config import ...`` -imports continue to work. New code should import from the canonical path. -""" - -from agentex.config.build_config import ( # noqa: F401 - BuildConfig, - BuildContext, -) diff --git a/src/agentex/lib/sdk/config/deployment_config.py b/src/agentex/lib/sdk/config/deployment_config.py deleted file mode 100644 index 6ed7ebed7..000000000 --- a/src/agentex/lib/sdk/config/deployment_config.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Back-compat shim. The canonical location is :mod:`agentex.config.deployment_config`. - -Kept here so existing ``from agentex.lib.sdk.config.deployment_config import ...`` -imports continue to work. New code should import from the canonical path. -""" - -from agentex.config.deployment_config import ( # noqa: F401 - ImageConfig, - ClusterConfig, - ResourceConfig, - DeploymentConfig, - AuthenticationConfig, - ResourceRequirements, - ImagePullSecretConfig, - InjectedSecretsValues, - GlobalDeploymentConfig, - InjectedImagePullSecretValues, -) diff --git a/src/agentex/lib/sdk/config/environment_config.py b/src/agentex/lib/sdk/config/environment_config.py deleted file mode 100644 index 3800ff518..000000000 --- a/src/agentex/lib/sdk/config/environment_config.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Back-compat shim and yaml loaders for environment configuration. - -The model classes' canonical location is -:mod:`agentex.config.environment_config`; they are re-exported here so existing -``from agentex.lib.sdk.config.environment_config import ...`` imports keep -working. The yaml-loading helpers stay here (CLI/build-side) so the promoted -models remain slim-safe. -""" - -from __future__ import annotations - -from pathlib import Path - -import yaml - -from agentex.config.environment_config import ( # noqa: F401 - AgentAuthConfig, - OciRegistryConfig, - AgentKubernetesConfig, - AgentEnvironmentConfig, - AgentEnvironmentsConfig, -) - - -def load_environments_config(file_path: str) -> AgentEnvironmentsConfig: - """Load and validate an environments.yaml into an AgentEnvironmentsConfig. - - Args: - file_path: Path to environments.yaml file - - Returns: - Parsed and validated AgentEnvironmentsConfig - - Raises: - FileNotFoundError: If file doesn't exist - ValueError: If file is invalid or doesn't validate - """ - path = Path(file_path) - if not path.exists(): - raise FileNotFoundError(f"environments.yaml not found: {file_path}") - - try: - with open(path, "r") as f: - data = yaml.safe_load(f) - - if not data: - raise ValueError("environments.yaml file is empty") - - return AgentEnvironmentsConfig.model_validate(data) - - except yaml.YAMLError as e: - raise ValueError(f"Invalid YAML format in {file_path}: {e}") from e - except Exception as e: - raise ValueError(f"Failed to load environments.yaml from {file_path}: {e}") from e - - -def load_environments_config_from_manifest_dir(manifest_dir: Path) -> AgentEnvironmentsConfig | None: - """Helper function to load environments.yaml from same directory as manifest.yaml. - - Args: - manifest_dir: Directory containing manifest.yaml - - Returns: - AgentEnvironmentsConfig if environments.yaml exists, None otherwise - - Raises: - ValueError: If environments.yaml exists but is invalid - """ - environments_file = manifest_dir / "environments.yaml" - if not environments_file.exists(): - return None - - return load_environments_config(str(environments_file)) diff --git a/src/agentex/lib/sdk/config/local_development_config.py b/src/agentex/lib/sdk/config/local_development_config.py deleted file mode 100644 index ce60c41c9..000000000 --- a/src/agentex/lib/sdk/config/local_development_config.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Back-compat shim. The canonical location is :mod:`agentex.config.local_development_config`. - -Kept here so existing ``from agentex.lib.sdk.config.local_development_config -import ...`` imports continue to work. New code should import from the canonical path. -""" - -from agentex.config.local_development_config import ( # noqa: F401 - LocalAgentConfig, - LocalPathsConfig, - LocalDevelopmentConfig, -) diff --git a/src/agentex/lib/sdk/config/project_config.py b/src/agentex/lib/sdk/config/project_config.py 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 12ecc1b4f..000000000 --- a/src/agentex/lib/sdk/config/validation.py +++ /dev/null @@ -1,256 +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.config.environment_config import AgentEnvironmentConfig, AgentEnvironmentsConfig -from agentex.lib.sdk.config.environment_config import load_environments_config - -logger = make_logger(__name__) - - -class ConfigValidationError(Exception): - """Exception raised when configuration validation fails.""" - - def __init__(self, message: str, file_path: Optional[str] = None): - self.file_path = file_path - super().__init__(message) - - -class EnvironmentsValidationError(ConfigValidationError): - """Exception raised when environments.yaml validation fails.""" - - pass - - -def validate_environments_config( - environments_config: AgentEnvironmentsConfig, required_environments: Optional[List[str]] = None -) -> None: - """ - Validate environments configuration with comprehensive checks. - - Args: - environments_config: The loaded environments configuration - required_environments: List of environment names that must be present - - Raises: - EnvironmentsValidationError: If validation fails - """ - # Check for required environments - if required_environments: - # this must exist as a top-level key or via the environment indicator - missing_envs: List[str] = [] - environment_mappings = [env.environment for env in environments_config.environments.values() if env.environment] - top_level_envs = [env for env in environments_config.environments] - all_envs = set(environment_mappings + top_level_envs) - for env_name in required_environments: - if env_name not in all_envs: - missing_envs.append(env_name) - - if missing_envs: - raise EnvironmentsValidationError( - f"Missing required environments: {', '.join(missing_envs)}. " - f"Available environments: {', '.join(all_envs)}" - ) - - # if environment mappings are set, you cannot have a top-level env_name that maps to an `environment: value` - # and another environment that has the mapping i.e. - # enviorments: - # dev: - # .... - # dev1: - # environment: dev - # this is invalid because its unclear if "dev" refers to just that top-level environment or the mapping - # - # Validate each environment configuration - for env_name, env_config in environments_config.environments.items(): - try: - _validate_single_environment_config(env_name, env_config) - except Exception as e: - raise EnvironmentsValidationError(f"Environment '{env_name}' configuration error: {str(e)}") from e - - -def _validate_single_environment_config(env_name: str, env_config: AgentEnvironmentConfig) -> None: - """ - Validate a single environment configuration. - - Args: - env_name: Name of the environment - env_config: AgentEnvironmentConfig instance - - Raises: - ValueError: If validation fails - """ - # Validate namespace naming conventions if kubernetes config exists - if env_config.kubernetes and env_config.kubernetes.namespace: - namespace = env_config.kubernetes.namespace - - # Check for common namespace naming issues - if namespace != namespace.lower(): - logger.warning( - f"Namespace '{namespace}' contains uppercase letters. Kubernetes namespaces should be lowercase." - ) - - if namespace.startswith("-") or namespace.endswith("-"): - raise ValueError(f"Namespace '{namespace}' cannot start or end with hyphens") - - # Validate auth principal - principal = env_config.auth.principal - user_id = principal.get("user_id") - service_account_id = principal.get("service_account_id") - if not user_id and not service_account_id: - raise ValueError("Auth principal must contain non-empty 'user_id' or 'service_account_id'") - if user_id and service_account_id: - raise ValueError("Auth principal must contain only one of 'user_id' or 'service_account_id', not both") - - # Check for environment-specific user_id patterns - if isinstance(user_id, str): - if not any(env_name.lower() in user_id.lower() for env_name in ["dev", "prod", "staging", env_name]): - logger.warning( - f"User ID '{user_id}' doesn't contain environment indicator. " - f"Consider including '{env_name}' in the user_id for clarity." - ) - - # Validate helm overrides if present - if env_config.helm_overrides: - _validate_helm_overrides(env_config.helm_overrides) - - -def _validate_helm_overrides(helm_overrides: Dict[str, Any]) -> None: - """ - Validate helm override configuration. - - Args: - helm_overrides: Dictionary of helm overrides - - Raises: - ValueError: If validation fails - """ - # Check for common helm override issues - if "resources" in helm_overrides: - resources = helm_overrides["resources"] - if isinstance(resources, dict): - # Validate resource format - if "requests" in resources or "limits" in resources: - for resource_type in ["requests", "limits"]: - if resource_type in resources: - resource_config: Any = resources[resource_type] - if isinstance(resource_config, dict): - # Check for valid resource specifications - for key, value in resource_config.items(): - if key in ["cpu", "memory"] and not isinstance(value, str): - logger.warning( - f"Resource {key} should be a string (e.g., '500m', '1Gi'), " - f"got {type(value).__name__}: {value}" - ) - - -def validate_environments_yaml_file(file_path: str) -> AgentEnvironmentsConfig: - """ - Load and validate environments.yaml file. - - Args: - file_path: Path to environments.yaml file - - Returns: - Validated AgentEnvironmentsConfig - - Raises: - EnvironmentsValidationError: If file is invalid - """ - try: - environments_config = load_environments_config(file_path) - validate_environments_config(environments_config) - return environments_config - except FileNotFoundError: - raise EnvironmentsValidationError( - f"environments.yaml not found: {file_path}\n\n" - "๐Ÿ“‹ Why required:\n" - " Environment-specific settings (auth, namespace, resources)\n" - " must be separated from global manifest for proper isolation.", - file_path=file_path, - ) from None - except Exception as e: - raise EnvironmentsValidationError(f"Invalid environments.yaml file: {str(e)}", file_path=file_path) from e - - -def validate_manifest_and_environments( - manifest_path: str, required_environment: Optional[str] = None -) -> tuple[str, AgentEnvironmentsConfig]: - """ - Validate both manifest.yaml and environments.yaml files together. - - Args: - manifest_path: Path to manifest.yaml file - required_environment: Specific environment that must be present - - Returns: - Tuple of (manifest_path, environments_config) - - Raises: - ConfigValidationError: If validation fails - """ - manifest_file = Path(manifest_path) - if not manifest_file.exists(): - raise ConfigValidationError(f"Manifest file not found: {manifest_path}") - - # Look for environments.yaml in same directory - environments_file = manifest_file.parent / "environments.yaml" - environments_config = validate_environments_yaml_file(str(environments_file)) - - # Validate specific environment if requested - if required_environment: - validate_environments_config(environments_config, required_environments=[required_environment]) - - return manifest_path, environments_config - - -def generate_helpful_error_message(error: Exception, context: str = "") -> str: - """ - Generate helpful error message with troubleshooting tips. - - Args: - error: The original exception - context: Additional context about where the error occurred - - Returns: - Formatted error message with troubleshooting tips - """ - base_msg = str(error) - - if context: - base_msg = f"{context}: {base_msg}" - - # Add troubleshooting tips based on error type - if isinstance(error, FileNotFoundError): - if "environments.yaml" in base_msg: - base_msg += ( - "\n\n๐Ÿ”ง Troubleshooting:\n" - "1. Check file location: should be next to manifest.yaml\n" - "2. Verify file permissions" - ) - elif "user_id" in base_msg.lower() or "service_account_id" in base_msg.lower(): - base_msg += ( - "\n\n๐Ÿ’ก Auth Principal Tips:\n" - "- Set exactly one of 'user_id' or 'service_account_id'\n" - "- The id should be unique per environment\n" - "- For user_id, include environment name (e.g., 'dev_my_agent')\n" - "- Use consistent naming convention across agents" - ) - elif "namespace" in base_msg.lower(): - base_msg += ( - "\n\n๐Ÿท๏ธ Namespace Tips:\n" - "- Use lowercase letters, numbers, and hyphens only\n" - "- Include team and environment (e.g., 'team-dev-agent')\n" - "- Keep under 63 characters" - ) - - return base_msg diff --git a/src/agentex/lib/sdk/fastacp/__init__.py b/src/agentex/lib/sdk/fastacp/__init__.py 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 1ea8e82e6..000000000 --- a/src/agentex/lib/sdk/fastacp/base/base_acp_server.py +++ /dev/null @@ -1,436 +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, - InterruptTaskParams, -) -from agentex.lib.utils.logging import make_logger, ctx_var_request_id -from agentex.protocol.json_rpc import JSONRPCError, JSONRPCRequest, JSONRPCResponse -from agentex.lib.utils.model_utils import BaseModel -from agentex.lib.utils.registration import register_agent - -# from agentex.lib.sdk.fastacp.types import BaseACPConfig -from agentex.lib.environment_variables import EnvironmentVariables, refreshed_environment_variables -from agentex.types.task_message_update import TaskMessageUpdate, StreamTaskMessageFull -from agentex.types.task_message_content import TaskMessageContent -from agentex.lib.core.tracing.span_queue import shutdown_default_span_queue -from agentex.lib.core.compat.version_guard import assert_backend_compatible -from agentex.lib.sdk.fastacp.base.constants import ( - FASTACP_HEADER_SKIP_EXACT, - FASTACP_HEADER_SKIP_PREFIXES, -) - -logger = make_logger(__name__) - -# Create a TypeAdapter for TaskMessageUpdate validation -task_message_update_adapter = TypeAdapter(TaskMessageUpdate) - - -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/interrupt โ†’ Interrupt the in-flight turn (non-terminal; task stays continuable) - - task/approve โ†’ Approve a task - """ - - def __init__(self): - super().__init__(lifespan=self.get_lifespan_function()) - - self.get("/healthz")(self._healthz) - self.post("/api")(self._handle_jsonrpc) - - # Method handlers - # this just adds a request ID to the request and response headers - self.add_middleware(RequestIDMiddleware) - self._handlers: dict[RPCMethod, Callable] = {} - - # Agent info to return in healthz - self.agent_id: str | None = None - - # Optional agent card for registration metadata - self._agent_card: Any | None = None - - @classmethod - def create(cls): - """Create and initialize BaseACPServer instance""" - instance = cls() - instance._setup_handlers() - return instance - - def _setup_handlers(self): - """Set up default handlers - override in subclasses""" - # Base class has no default handlers - pass - - def get_lifespan_function(self): - @asynccontextmanager - async def lifespan_context(app: FastAPI): # noqa: ARG001 - env_vars = EnvironmentVariables.refresh() - if env_vars.AGENTEX_BASE_URL: - # Runtime SDK<->backend contract guard: fail fast if the backend is older - # than this SDK supports, instead of opaque 500s later. See compat.version_guard. - await assert_backend_compatible(env_vars.AGENTEX_BASE_URL) - await register_agent(env_vars, agent_card=self._agent_card) - self.agent_id = env_vars.AGENT_ID - else: - logger.warning("AGENTEX_BASE_URL not set, skipping agent registration") - - try: - yield - finally: - await shutdown_default_span_queue() - - return lifespan_context - - async def _healthz(self): - """Health check endpoint""" - result = {"status": "healthy"} - if self.agent_id: - result["agent_id"] = self.agent_id - return result - - def _wrap_handler(self, fn: Callable[..., Awaitable[Any]]): - """Wraps handler functions to provide JSON-RPC 2.0 response format""" - - async def wrapper(*args, **kwargs) -> Any: - return await fn(*args, **kwargs) - - return wrapper - - async def _handle_jsonrpc(self, request: Request): - """Main JSON-RPC endpoint handler""" - rpc_request = None - logger.info(f"[base_acp_server] received request: {datetime.now()}") - try: - data = await request.json() - rpc_request = JSONRPCRequest(**data) - - # Check if the request is authenticated - if refreshed_environment_variables and getattr(refreshed_environment_variables, "AGENT_API_KEY", None): - authorization_header = request.headers.get("x-agent-api-key") - if authorization_header != refreshed_environment_variables.AGENT_API_KEY: - return JSONRPCResponse( - id=rpc_request.id, - error=JSONRPCError(code=-32601, message="Unauthorized"), - ) - - - # Check if method is valid first - try: - method = RPCMethod(rpc_request.method) - except ValueError: - logger.error(f"Method {rpc_request.method} was invalid") - return JSONRPCResponse( - id=rpc_request.id, - error=JSONRPCError( - code=-32601, message=f"Method {rpc_request.method} not found" - ), - ) - - if method not in self._handlers or self._handlers[method] is None: - logger.error(f"Method {method} not found on existing ACP server") - return JSONRPCResponse( - id=rpc_request.id, - error=JSONRPCError( - code=-32601, message=f"Method {method} not found" - ), - ) - - # Extract application headers using allowlist approach (only x-* headers) - # Matches gateway's security filtering rules - # Forward filtered headers via params.request.headers to agent handlers - custom_headers = { - key: value - for key, value in request.headers.items() - if key.lower().startswith("x-") - and key.lower() not in FASTACP_HEADER_SKIP_EXACT - and not any(key.lower().startswith(p) for p in FASTACP_HEADER_SKIP_PREFIXES) - } - - # Parse params into appropriate model based on method and include headers - params_model = PARAMS_MODEL_BY_METHOD[method] - params_data = dict(rpc_request.params) if rpc_request.params else {} - - # Add custom headers to the request structure if any headers were provided - # Gateway sends filtered headers via HTTP, SDK extracts and populates params.request - if custom_headers: - params_data["request"] = {"headers": custom_headers} - params = params_model.model_validate(params_data) - - if method in RPC_SYNC_METHODS: - handler = self._handlers[method] - result = await handler(params) - - if rpc_request.id is None: - # Seems like you should return None for notifications - return None - else: - # Handle streaming vs non-streaming for MESSAGE_SEND - if method == RPCMethod.MESSAGE_SEND and isinstance( - result, AsyncGenerator - ): - return await self._handle_streaming_response( - rpc_request.id, result - ) - else: - if isinstance(result, BaseModel): - result = result.model_dump() - return JSONRPCResponse(id=rpc_request.id, result=result) - else: - # If this is a notification (no request ID), process in background and return immediately - if rpc_request.id is None: - asyncio.create_task(self._process_notification(method, params)) - return JSONRPCResponse(id=None) - - # For regular requests, start processing in background but return immediately - asyncio.create_task( - self._process_request(rpc_request.id, method, params) - ) - - # Return immediate acknowledgment - return JSONRPCResponse( - id=rpc_request.id, result={"status": "processing"} - ) - - except Exception as e: - logger.error(f"Error handling JSON-RPC request: {e}", exc_info=True) - request_id = None - if rpc_request is not None: - request_id = rpc_request.id - return JSONRPCResponse( - id=request_id, - error=JSONRPCError(code=-32603, message=str(e)).model_dump(), - ) - - async def _handle_streaming_response( - self, request_id: int | str, async_gen: AsyncGenerator - ): - """Handle streaming response by formatting TaskMessageUpdate objects as JSON-RPC stream""" - - async def generate_json_rpc_stream(): - try: - async for chunk in async_gen: - # Each chunk should be a TaskMessageUpdate object - # Validate using Pydantic's TypeAdapter to ensure it's a proper TaskMessageUpdate - try: - # This will validate that chunk conforms to the TaskMessageUpdate union type - validated_chunk = task_message_update_adapter.validate_python( - chunk - ) - # Use mode="json" to properly serialize datetime objects - chunk_data = validated_chunk.model_dump(mode="json") - except ValidationError as e: - raise TypeError( - f"Streaming chunks must be TaskMessageUpdate objects. Validation error: {e}" - ) from e - except Exception as e: - raise TypeError( - f"Streaming chunks must be TaskMessageUpdate objects, got {type(chunk)}: {e}" - ) from e - - # Wrap in JSON-RPC response format - response = JSONRPCResponse(id=request_id, result=chunk_data) - # Use model_dump_json() which handles datetime serialization automatically - yield f"{response.model_dump_json()}\n" - - except Exception as e: - logger.error(f"Error in streaming response: {e}", exc_info=True) - error_response = JSONRPCResponse( - id=request_id, - error=JSONRPCError(code=-32603, message=str(e)).model_dump(), - ) - yield f"{error_response.model_dump_json()}\n" - - return StreamingResponse( - generate_json_rpc_stream(), - media_type="application/x-ndjson", # Newline Delimited JSON - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", # Disable nginx buffering - }, - ) - - async def _process_notification(self, method: RPCMethod, params: Any): - """Process a notification (request with no ID) in the background""" - try: - handler = self._handlers[method] - await handler(params) - except Exception as e: - logger.error(f"Error processing notification {method}: {e}", exc_info=True) - - async def _process_request( - self, request_id: int | str, method: RPCMethod, params: Any - ): - """Process a request in the background""" - try: - handler = self._handlers[method] - await handler(params) - # Note: In a real implementation, you might want to store the result somewhere - # or notify the client through a different mechanism - logger.info( - f"Successfully processed request {request_id} for method {method}" - ) - except Exception as e: - logger.error( - f"Error processing request {request_id} for method {method}: {e}", - exc_info=True, - ) - - """ - Define all possible decorators to be overriden and implemented by each ACP implementation - Then the users can override the default handlers by implementing their own handlers - - ACP Type: Async - Decorators: - - on_task_create - - on_task_event_send - - on_task_cancel - - on_task_interrupt - - ACP Type: Sync - Decorators: - - on_message_send - """ - - # Type: Async - def on_task_create(self, fn: Callable[[CreateTaskParams], Awaitable[Any]]): - """Handle task/init method""" - wrapped = self._wrap_handler(fn) - self._handlers[RPCMethod.TASK_CREATE] = wrapped - return fn - - # Type: Async - def on_task_event_send(self, fn: Callable[[SendEventParams], Awaitable[Any]]): - """Handle event/send method""" - - async def wrapped_handler(params: SendEventParams): - # # # Send message to client first most of the time - # ## But, sometimes you may want to process the message first - # ## and then send a message to the client - # await agentex.interactions.send_messages_to_client( - # task_id=params.task_id, - # messages=[params.message] - # ) - return await fn(params) - - wrapped = self._wrap_handler(wrapped_handler) - self._handlers[RPCMethod.EVENT_SEND] = wrapped - return fn - - # Type: Async - def on_task_cancel(self, fn: Callable[[CancelTaskParams], Awaitable[Any]]): - """Handle task/cancel method""" - wrapped = self._wrap_handler(fn) - self._handlers[RPCMethod.TASK_CANCEL] = wrapped - return fn - - # Type: Async - def on_task_interrupt(self, fn: Callable[[InterruptTaskParams], Awaitable[Any]]): - """Handle task/interrupt method. - - Non-terminal counterpart to ``on_task_cancel``: forwards the interrupt to - the agent so it can stop the in-flight turn while leaving the task - continuable. See the interrupt-and-queue design doc, section 7. - """ - wrapped = self._wrap_handler(fn) - self._handlers[RPCMethod.TASK_INTERRUPT] = wrapped - return fn - - # Type: Sync - def on_message_send( - self, - fn: Callable[ - [SendMessageParams], - Awaitable[TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]], - ], - ): - """Handle message/send method - supports both single and streaming responses - - For non-streaming: return a single TaskMessage - For streaming: return an AsyncGenerator that yields TaskMessageUpdate objects - """ - - async def message_send_wrapper(params: SendMessageParams): - """Special wrapper for message_send that handles both regular async functions and async generators""" - # Check if the function is an async generator function - - # Regardless of whether the Agent developer implemented an Async generator or not, we will always turn the function into an async generator and yield SSE events back tot he Agentex server so there is only one way for it to process the response. Then, based on the client's desire to stream or not, the Agentex server will either yield back the async generator objects directly (if streaming) or aggregate the content into a list of TaskMessageContents and to dispatch to the client. This basically gives the Agentex server the flexibility to handle both cases itself. - - if inspect.isasyncgenfunction(fn): - # The client wants streaming, an async generator already streams the content, so just return it - return fn(params) - else: - # The client wants streaming, but the function is not an async generator, so we turn it into one and yield each TaskMessageContent as a StreamTaskMessageFull which will be streamed to the client by the Agentex server. - task_message_content_response = await fn(params) - # Handle None returns gracefully - treat as empty list - if task_message_content_response is None: - task_message_content_list = [] - elif isinstance(task_message_content_response, list): - # Filter out None values from lists - task_message_content_list = [content for content in task_message_content_response if content is not None] - else: - task_message_content_list = [task_message_content_response] - - async def async_generator(task_message_content_list: list[TaskMessageContent]): - for i, task_message_content in enumerate(task_message_content_list): - yield StreamTaskMessageFull(type="full", index=i, content=task_message_content) - - return async_generator(task_message_content_list) - - self._handlers[RPCMethod.MESSAGE_SEND] = message_send_wrapper - return fn - - """ - End of Decorators - """ - - """ - ACP Server Lifecycle Methods - """ - - def run(self, host: str = "0.0.0.0", port: int = 8000, **kwargs): - """Start the Uvicorn server for async handlers.""" - uvicorn.run(self, host=host, port=port, **kwargs) - - diff --git a/src/agentex/lib/sdk/fastacp/base/constants.py b/src/agentex/lib/sdk/fastacp/base/constants.py 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 1a9cce7a8..000000000 --- a/src/agentex/lib/sdk/fastacp/impl/temporal_acp.py +++ /dev/null @@ -1,153 +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, - InterruptTaskParams, -) -from agentex.lib.utils.logging import make_logger -from agentex.lib.environment_variables import EnvironmentVariables -from agentex.lib.sdk.fastacp.base.base_acp_server import BaseACPServer -from agentex.lib.core.clients.temporal.temporal_client import TemporalClient -from agentex.lib.core.temporal.services.temporal_task_service import TemporalTaskService - -logger = make_logger(__name__) - - -class TemporalACP(BaseACPServer): - """ - Temporal-specific implementation of AsyncAgentACP. - Uses TaskService to forward operations to temporal workflows. - """ - - def __init__( - self, - temporal_address: str, - temporal_task_service: TemporalTaskService | None = None, - plugins: list[Any] | None = None, - interceptors: list[Any] | None = None, - payload_codec: PayloadCodec | None = None, - data_converter: DataConverter | None = None, - ): - super().__init__() - self._temporal_task_service = temporal_task_service - self._temporal_address = temporal_address - self._plugins = plugins or [] - self._interceptors = interceptors or [] - self._payload_codec = payload_codec - self._data_converter = data_converter - - @classmethod - @override - def create( - cls, - temporal_address: str, - plugins: list[Any] | None = None, - interceptors: list[Any] | None = None, - payload_codec: PayloadCodec | None = None, - data_converter: DataConverter | None = None, - ) -> "TemporalACP": - logger.info("Initializing TemporalACP instance") - - # Create instance without temporal client initially - temporal_acp = cls( - temporal_address=temporal_address, - plugins=plugins, - interceptors=interceptors, - payload_codec=payload_codec, - data_converter=data_converter, - ) - temporal_acp._setup_handlers() - logger.info("TemporalACP instance initialized now") - return temporal_acp - - @override - def get_lifespan_function(self) -> Callable[[FastAPI], AsyncGenerator[None, None]]: - @asynccontextmanager - async def lifespan(app: FastAPI): - # Create temporal client during startup - if self._temporal_address is None: - raise ValueError("Temporal address is not set") - - if self._temporal_task_service is None: - env_vars = EnvironmentVariables.refresh() - temporal_client = await TemporalClient.create( - temporal_address=self._temporal_address, - plugins=self._plugins, - payload_codec=self._payload_codec, - data_converter=self._data_converter, - ) - self._temporal_task_service = TemporalTaskService( - temporal_client=temporal_client, - env_vars=env_vars, - ) - - # Call parent lifespan for agent registration - async with super().get_lifespan_function()(app): # type: ignore[misc] - yield - - return lifespan # type: ignore[return-value] - - @override - def _setup_handlers(self): - """Set up the handlers for temporal workflow operations""" - - @self.on_task_create - async def handle_task_create(params: CreateTaskParams) -> None: - """Default create task handler - logs the task""" - logger.info(f"TemporalACP received task create rpc call for task {params.task.id}") - if self._temporal_task_service is not None: - await self._temporal_task_service.submit_task( - agent=params.agent, task=params.task, params=params.params - ) - - @self.on_task_event_send - async def handle_event_send(params: SendEventParams) -> None: - """Forward messages to running workflows via TaskService""" - try: - if self._temporal_task_service is not None: - await self._temporal_task_service.send_event( - agent=params.agent, - task=params.task, - event=params.event, - request=params.request, - ) - - except Exception as e: - logger.error(f"Failed to send message: {e}") - raise - - @self.on_task_cancel - async def handle_cancel(params: CancelTaskParams) -> None: - """Cancel running workflows via TaskService""" - try: - if self._temporal_task_service is not None: - await self._temporal_task_service.cancel(task_id=params.task.id) - except Exception as e: - logger.error(f"Failed to cancel task: {e}") - raise - - @self.on_task_interrupt - async def handle_interrupt(params: InterruptTaskParams) -> None: - """Forward task/interrupt to the running workflow via TaskService. - - Non-terminal: signals the workflow's ``interrupt_turn`` handler rather - than tearing the workflow down, so the task stays continuable. - """ - try: - if self._temporal_task_service is not None: - await self._temporal_task_service.interrupt( - agent=params.agent, - task=params.task, - request=params.request, - ) - except Exception as e: - logger.error(f"Failed to interrupt task: {e}") - raise diff --git a/src/agentex/lib/sdk/fastacp/tests/README.md b/src/agentex/lib/sdk/fastacp/tests/README.md 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 5679a6bd8..000000000 --- a/src/agentex/lib/sdk/state_machine/state_machine.py +++ /dev/null @@ -1,221 +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. - """ - span = None - if self._trace_transitions: - if self._task_id is None: - raise ValueError( - "Task ID is must be set before tracing can be enabled" - ) - span = await adk.tracing.start_span( - trace_id=self._task_id, - name="state_transition_reset", - input={"input_state": self.get_current_state()}, - ) - - await self.transition(self._initial_state) - - if self._trace_transitions and span is not None: - span.output = {"output_state": self._initial_state} # type: ignore[assignment,union-attr] - await adk.tracing.end_span(trace_id=self._task_id, span=span) - - def get_lifecycle(self) -> dict[str, Any]: - """Export the state machine's lifecycle as a dict suitable for AgentCard.""" - states = [] - for state in self._state_map.values(): - workflow = state.workflow - states.append({ - "name": state.name, - "description": workflow.description, - "waits_for_input": workflow.waits_for_input, - "accepts": list(workflow.accepts), - "transitions": [ - t.value if isinstance(t, Enum) else str(t) - for t in workflow.transitions - ], - }) - initial: str = self._initial_state.value if isinstance(self._initial_state, Enum) else self._initial_state - - return { - "states": states, - "initial_state": initial, - } - - def dump(self) -> dict[str, Any]: - """ - Save the current state of the state machine to a serializable dictionary. - This includes the current state, task_id, state machine data, and initial state. - - Returns: - Dict[str, Any]: A dictionary containing the serialized state machine state - """ - return { - "task_id": self._task_id, - "current_state": self.get_current_state(), - "initial_state": self._initial_state, - "state_machine_data": self.state_machine_data.model_dump(mode="json") - if self.state_machine_data - else None, - "trace_transitions": self._trace_transitions, - } - - @classmethod - async def load(cls, data: dict[str, Any], states: list[State]) -> "StateMachine[T]": - """ - Load a state machine from a previously saved dictionary. - - Args: - data: The dictionary containing the saved state machine state - states: List of all possible states - - Returns: - StateMachine: A new state machine instance restored to the saved state - - Raises: - ValueError: If the data is invalid or missing required fields - """ - try: - task_id = data.get("task_id") - current_state_name = data.get("current_state") - initial_state = data.get("initial_state") - state_machine_data_dict = data.get("state_machine_data") - trace_transitions = data.get("trace_transitions") - - if initial_state is None: - raise ValueError("Initial state not found in saved data") - - # Reconstruct the state machine data into its Pydantic model - state_machine_data = None - if state_machine_data_dict is not None: - # Get the actual model type from the class's type parameters - model_type = cls.__orig_bases__[0].__args__[0] # type: ignore[attr-defined] - state_machine_data = model_type.model_validate(state_machine_data_dict) - - # Create a new instance - instance = cls( - initial_state=initial_state, - states=states, - task_id=task_id, - state_machine_data=state_machine_data, - trace_transitions=trace_transitions, - ) - - # If there's a saved state, transition to it - if current_state_name: - await instance.transition(target_state_name=current_state_name) - - return instance - except Exception as e: - raise ValueError(f"Failed to restore state machine: {str(e)}") from e diff --git a/src/agentex/lib/sdk/state_machine/state_workflow.py b/src/agentex/lib/sdk/state_machine/state_workflow.py 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/sdk/utils/webhooks.py b/src/agentex/lib/sdk/utils/webhooks.py deleted file mode 100644 index d4b7b43e1..000000000 --- a/src/agentex/lib/sdk/utils/webhooks.py +++ /dev/null @@ -1,389 +0,0 @@ -"""Drive an agent turn from an inbound webhook, inside a forward-route handler. - -The Agentex server already exposes a webhook ingress: a request to -``/agents/forward/name/{agent}/{path}`` is signature-verified (GitHub ``sha256=`` / -Slack ``v0:`` HMAC via the agent's registered keys) and proxied to the agent's own -HTTP route. This helper is what that route handler calls to turn the inbound payload -into an agent turn โ€” without each agent re-implementing payload shaping, config -resolution, session continuity, and reply handling. - -Typical use inside an agent:: - - from fastapi import Request - from agentex.lib.sdk.utils.webhooks import handle_webhook - - - @acp.post("/github-pr") - async def github_pr(request: Request): - body = await request.json() - result = await handle_webhook( - agent_name="my-agent", - payload=body, - acp_type="sync", - shaper="github_pr", - params_source="https:///public/v5/agent_configs//resolve", - params_source_headers={"x-api-key": ..., "x-selected-account-id": ...}, - wait=True, - ) - return {"task_id": result.task_id, "reply": result.reply} - -Config-by-id: pass ``params_source`` pointing at the platform's config-resolve -endpoint; the resolved params (e.g. system_prompt / harness / model / tools) are -forwarded opaquely to ``task/create``. Or pass inline ``params`` for a one-off. -""" - -from __future__ import annotations - -import json -import hashlib -from typing import Any, Literal -from dataclasses import field, dataclass -from collections.abc import Mapping, Callable, Awaitable - -from agentex.lib import adk -from agentex.lib.utils.logging import make_logger -from agentex.types.task_message_content import TextContent - -logger = make_logger(__name__) - -# Injectable params fetcher (url -> JSON). Default uses httpx; tests inject a fake. -ParamsFetcher = Callable[[str], Awaitable[dict[str, Any]]] - -MAX_BODY_CHARS = 4000 -MAX_DIFF_CHARS = 30000 - - -class WebhookError(RuntimeError): - """Raised when a webhook turn cannot be driven (e.g. params resolution failed).""" - - -@dataclass -class WebhookResult: - task_id: str - # Sync agents reply inline. For async agents, ``reply`` is None unless ``wait`` was - # set, in which case it is the polled reply (or None if it didn't settle in time). - reply: str | None = None - task_metadata: dict[str, str] = field(default_factory=dict) - - -# --------------------------------------------------------------------------- shaping - - -def session_key(agent_name: str, channel: str, peer_id: str) -> str: - """Stable per-conversation task name โ†’ reused for get-or-create on task/create, so - repeat events from the same source fold into one task instead of spawning new ones.""" - basis = peer_id or "main" - digest = hashlib.sha1(f"{agent_name}:{channel}:{basis}".encode()).hexdigest()[:16] - return f"wh-{channel}-{digest}" - - -# Top-level fields a generic webhook payload might carry its prompt in, in priority -# order. Matched case-insensitively against the payload's keys. -GENERIC_PROMPT_KEYS = ( - "text", - "message", - "prompt", - "goal", - "content", - "body", - "description", - "title", -) - - -def render_generic(body: dict[str, Any]) -> str: - """Generic payload โ†’ prompt text: first non-empty string among GENERIC_PROMPT_KEYS - (case-insensitive), else raw JSON.""" - lowered = {key.lower(): value for key, value in body.items() if isinstance(key, str)} - for key in GENERIC_PROMPT_KEYS: - value = lowered.get(key) - if isinstance(value, str) and value.strip(): - return value.strip() - return json.dumps(body, indent=2)[:8000] - - -def shape_github_pr(body: dict[str, Any]) -> tuple[str, str | None, str]: - """Shape a GitHub/Gitea pull-request webhook into (prompt, peer_id, sender). - - ``peer_id`` is ``repo#number`` so repeated events for the same PR (opened, - synchronize, ...) fold into one task. Falls back to generic rendering for non-PR - payloads (ping, issue, ...). - """ - pull_request = body.get("pull_request") - if not isinstance(pull_request, dict): - return render_generic(body), None, _github_actor(body) - - repo = _repo_full_name(body) - number = pull_request.get("number") - title = (pull_request.get("title") or "").strip() - action = (body.get("action") or "").strip() - description = (pull_request.get("body") or "").strip() - html_url = pull_request.get("html_url") or pull_request.get("url") - - header = "Pull request" - if repo and number is not None: - header = f"Pull request {repo}#{number}" - elif number is not None: - header = f"Pull request #{number}" - - lines = [f"{header}: {title}" if title else header] - if action: - lines.append(f"Action: {action}") - if html_url: - lines.append(f"URL: {html_url}") - if description: - lines.extend(["", "Description:", description[:MAX_BODY_CHARS]]) - - diff = _inline_diff(body, pull_request) - if diff: - lines.extend(["", "Diff:", diff[:MAX_DIFF_CHARS]]) - else: - # Standard GitHub/Gitea payloads carry a diff/patch URL, not the patch body. - # Surface it so a tool-enabled agent (or the caller) can fetch the diff; inline - # `diff` wins. Gitea sends patch_url alongside diff_url, so accept either. - diff_url = pull_request.get("diff_url") or pull_request.get("patch_url") - if diff_url: - lines.extend(["", f"Diff URL: {diff_url}"]) - - peer_id = None - if repo and number is not None: - peer_id = f"{repo}#{number}" - elif number is not None: - peer_id = f"pr#{number}" - return "\n".join(lines), peer_id, _github_actor(body) - - -def _repo_full_name(body: dict[str, Any]) -> str | None: - repo = body.get("repository") - if isinstance(repo, dict) and isinstance(repo.get("full_name"), str): - return repo["full_name"] or None - return None - - -def _github_actor(body: dict[str, Any]) -> str: - sender = body.get("sender") - if isinstance(sender, dict) and isinstance(sender.get("login"), str) and sender["login"]: - return sender["login"] - return "webhook" - - -def _inline_diff(body: dict[str, Any], pull_request: dict[str, Any]) -> str | None: - for source in (body, pull_request): - diff = source.get("diff") - if isinstance(diff, str) and diff.strip(): - return diff.strip() - return None - - -# ------------------------------------------------------------------- params resolution - - -async def _default_fetch(url: str, headers: dict[str, str]) -> dict[str, Any]: - """GET a params source over HTTP. Imported lazily so callers that only pass inline - params carry no httpx dependency.""" - import httpx - - request_headers = {"accept": "application/json", **headers} - try: - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.get(url, headers=request_headers) - response.raise_for_status() - return response.json() - except httpx.HTTPError as exc: - raise WebhookError(f"params source request failed: {exc}") from exc - except ValueError as exc: # json.JSONDecodeError subclasses ValueError - raise WebhookError(f"params source returned invalid JSON: {exc}") from exc - - -async def resolve_remote_params( - url: str, - headers: dict[str, str] | None = None, - *, - fetch: ParamsFetcher | None = None, -) -> tuple[dict[str, Any], dict[str, str]]: - """Fetch params (+ optional task_metadata) from a config-resolve URL. - - Response shape (lenient):: - - {"params": {...}, "task_metadata": {...}} - - A bare object with no ``params`` key is treated as the params dict itself (minus a - top-level ``task_metadata``, which is returned separately for stamping). - """ - do_fetch = fetch or (lambda u: _default_fetch(u, headers or {})) - payload = await do_fetch(url) - if not isinstance(payload, dict): - raise WebhookError("params source returned a non-object response") - - metadata_raw = payload.get("task_metadata") - task_metadata = {str(k): str(v) for k, v in metadata_raw.items()} if isinstance(metadata_raw, dict) else {} - params = payload.get("params") - if not isinstance(params, dict): - params = {k: v for k, v in payload.items() if k != "task_metadata"} - return params, task_metadata - - -# ------------------------------------------------------------------------- dispatch - - -def _agent_reply_text(messages: object) -> str | None: - """Join agent-authored text from a message list (sync result or polled stream).""" - if not isinstance(messages, list): - return None - parts = [] - for message in messages: - content = getattr(message, "content", None) - if ( - content is not None - and getattr(content, "author", None) == "agent" - and getattr(content, "type", None) == "text" - ): - text = (getattr(content, "content", "") or "").strip() - if text: - parts.append(text) - return "\n\n".join(parts) if parts else None - - -async def handle_webhook( - *, - agent_name: str, - payload: dict[str, Any], - acp_type: Literal["sync", "async"] = "sync", - shaper: Literal["generic", "github_pr"] = "generic", - channel: str | None = None, - params: dict[str, Any] | None = None, - params_source: str | None = None, - params_source_headers: dict[str, str] | None = None, - peer_id: str | None = None, - extra_task_metadata: dict[str, str] | None = None, - wait: bool = False, - fetch: ParamsFetcher | None = None, -) -> WebhookResult: - """Drive an agent turn from a webhook payload, agent-side, via the ADK client. - - - Shapes the payload (generic or GitHub PR) into a prompt + conversation scope. - - Resolves task params: inline ``params``, or fetched from ``params_source`` - (config-by-id). The platform never interprets params โ€” they're forwarded to the - agent as ``task/create`` params. - - Get-or-creates a task keyed on a stable session key, so repeat events fold in. - - Sends the turn (sync โ†’ message/send returns the reply inline; async โ†’ event/send, - with optional ``wait`` to poll for the reply). - """ - channel = channel or shaper - if shaper == "github_pr": - text, derived_peer, sender = shape_github_pr(payload) - peer_id = peer_id or derived_peer - else: - text, sender = render_generic(payload), "webhook" - - task_metadata: dict[str, str] = {"channel": channel, "sender_id": sender} - if peer_id: - task_metadata["peer_id"] = peer_id - - resolved_params = dict(params) if params else {} - if params_source: - resolved_params, source_metadata = await resolve_remote_params( - params_source, params_source_headers, fetch=fetch - ) - # Source metadata + caller extras never override the canonical fields above. - for key, value in {**source_metadata, **(extra_task_metadata or {})}.items(): - task_metadata.setdefault(key, str(value)) - elif extra_task_metadata: - for key, value in extra_task_metadata.items(): - task_metadata.setdefault(key, str(value)) - - name = session_key(agent_name, channel, peer_id or "") - # task/create carries only name/params (CreateTaskParams has no task_metadata field), - # so we create first, then stamp task_metadata via a follow-up update below. - task = await adk.acp.create_task( - name=name, - agent_name=agent_name, - params=resolved_params or None, - ) - - # Best-effort: stamp the resolved task_metadata (channel/sender/peer_id, plus the - # display_name etc. from params_source) onto the task so it's labeled in the UI. - # Failure must never break the run โ€” the metadata is also returned on the result. - if task_metadata: - try: - merged_task_metadata = { - **_task_metadata_dict(getattr(task, "task_metadata", None)), - **task_metadata, - } - await adk.tasks.update(task_id=task.id, task_metadata=merged_task_metadata) - except Exception: - logger.warning("Failed to stamp task_metadata on task %s", task.id, exc_info=True) - - content = TextContent(author="user", content=text, format="markdown") - - if acp_type == "sync": - messages = await adk.acp.send_message(task_id=task.id, agent_name=agent_name, content=content) - return WebhookResult(task_id=task.id, reply=_agent_reply_text(messages), task_metadata=task_metadata) - - # Async: when we'll wait for the reply, snapshot existing message ids BEFORE the - # event so a reused task's prior reply (session continuity) isn't mistaken for it. - if wait: - seen_ids, seen_count = await _message_snapshot(task.id) - await adk.acp.send_event(task_id=task.id, agent_name=agent_name, content=content) - reply = await _await_reply(task.id, seen_ids, seen_count=seen_count) - else: - await adk.acp.send_event(task_id=task.id, agent_name=agent_name, content=content) - reply = None - return WebhookResult(task_id=task.id, reply=reply, task_metadata=task_metadata) - - -def _task_metadata_dict(value: object) -> dict[str, Any]: - if isinstance(value, Mapping): - return dict(value) - return {} - - -async def _message_snapshot(task_id: str) -> tuple[set[str], int]: - messages = await adk.messages.list(task_id=task_id) - messages = messages or [] - return {mid for m in messages if (mid := getattr(m, "id", None)) is not None}, len(messages) - - -async def _message_ids(task_id: str) -> set[str]: - # Only track real ids. Keeping None in the set would let a later id-less message - # collide with it and be wrongly treated as already-seen (dropping a fresh reply). - seen_ids, _ = await _message_snapshot(task_id) - return seen_ids - - -async def _await_reply( - task_id: str, - seen_ids: set[str | None], - *, - seen_count: int | None = None, - timeout_s: float = 120.0, - interval_s: float = 2.0, - quiescence_s: float = 6.0, -) -> str | None: - """Poll for THIS turn's reply โ€” agent text in messages that weren't present before - the event โ€” until it settles (unchanged for ``quiescence_s``) or times out. Filtering - on new message ids avoids returning a stale prior reply on a reused task.""" - import asyncio - - waited = 0.0 - last: str | None = None - stable_for = 0.0 - while waited < timeout_s: - await asyncio.sleep(interval_s) - waited += interval_s - messages = await adk.messages.list(task_id=task_id) - new = [] - for index, message in enumerate(messages or []): - mid = getattr(message, "id", None) - if mid is not None and mid not in seen_ids: - new.append(message) - elif mid is None and seen_count is not None and index >= seen_count: - new.append(message) - text = _agent_reply_text(new) - if text and text == last: - stable_for += interval_s - if stable_for >= quiescence_s: - return text - elif text: - last, stable_for = text, 0.0 - return last diff --git a/src/agentex/lib/types/__init__.py b/src/agentex/lib/types/__init__.py 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 e86ff7ddf..000000000 --- a/src/agentex/lib/types/acp.py +++ /dev/null @@ -1,16 +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, - InterruptTaskParams, -) 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 2c855d3df..000000000 --- a/src/agentex/lib/types/agent_configs.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Back-compat shim. The canonical location is :mod:`agentex.config.agent_configs`. - -Kept here so existing ``from agentex.lib.types.agent_configs import ...`` imports -continue to work. New code should import from the canonical path. -""" - -from agentex.config.agent_configs import ( # noqa: F401 - TemporalConfig, - TemporalWorkerConfig, - TemporalWorkflowConfig, -) diff --git a/src/agentex/lib/types/agent_results.py b/src/agentex/lib/types/agent_results.py 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 abab8b508..000000000 --- a/src/agentex/lib/types/credentials.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Back-compat shim. The canonical location is :mod:`agentex.config.credentials`. - -Kept here so existing ``from agentex.lib.types.credentials import ...`` imports -continue to work. New code should import from the canonical path. -""" - -from agentex.config.credentials import CredentialMapping # noqa: F401 diff --git a/src/agentex/lib/types/fastacp.py b/src/agentex/lib/types/fastacp.py 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 3cb1d7b4d..000000000 --- a/src/agentex/lib/utils/completions.py +++ /dev/null @@ -1,148 +0,0 @@ -from __future__ import annotations - -from copy import deepcopy -from typing import Any -from functools import reduce, singledispatch -from itertools import zip_longest - -from agentex.lib.types.llm_messages import ( - Delta, - Usage, - Choice, - ToolCall, - Completion, - ToolCallRequest, -) - - -@singledispatch -def _concat_chunks(_a: None, b: Any): - return b - - -@_concat_chunks.register -def _(a: Completion, b: Completion) -> Completion: - # Chunks can have unequal choices: with stream_options.include_usage the - # final chunk carries usage but no choices. Keep the unpaired side instead - # of truncating to the shorter list. - a.choices = [ - x if y is None else y if x is None else _concat_chunks(x, y) for x, y in zip_longest(a.choices, b.choices) - ] - a.usage = _concat_chunks(a.usage, b.usage) - - return a - - -@_concat_chunks.register -def _(a: Choice, b: Choice) -> Choice: - if hasattr(a, "index") and hasattr(b, "index"): - assert a.index == b.index - - if hasattr(a, "delta") and hasattr(b, "delta"): - a.delta = _concat_chunks(a.delta, b.delta) - - a.finish_reason = a.finish_reason or b.finish_reason - return a - - -@_concat_chunks.register -def _(a: Usage | None, b: Usage | None) -> Usage | None: - if a is not None and b is not None: - return Usage( - prompt_tokens=a.prompt_tokens + b.prompt_tokens, - completion_tokens=a.completion_tokens + b.completion_tokens, - total_tokens=a.total_tokens + b.total_tokens, - ) - else: - return a or b - - -@_concat_chunks.register -def _(a: Delta, b: Delta) -> Delta: - a.content = a.content + b.content if a.content and b.content else a.content or b.content - - if hasattr(a, "tool_calls") and hasattr(b, "tool_calls") and a.tool_calls and b.tool_calls: - # Group tool calls by index - grouped_tool_calls = {} - for tool_call in a.tool_calls + b.tool_calls: - if tool_call.index not in grouped_tool_calls: - grouped_tool_calls[tool_call.index] = tool_call - else: - grouped_tool_calls[tool_call.index] = _concat_chunks(grouped_tool_calls[tool_call.index], tool_call) - - a.tool_calls = list(grouped_tool_calls.values()) - elif hasattr(b, "tool_calls") and b.tool_calls: - a.tool_calls = b.tool_calls - - return a - - -@_concat_chunks.register -def _(a: ToolCallRequest, b: ToolCallRequest) -> ToolCallRequest: - # Preserve id from either a or b, with preference for a - id_val = a.id if a.id is not None else b.id - - # Use index from either a or b, with preference for a's index - index_val = a.index if hasattr(a, "index") and a.index is not None else b.index - - # Concatenate the function part - function_val = _concat_chunks(a.function, b.function) if a.function and b.function else a.function or b.function - - # Set all properties - a.id = id_val - a.index = index_val - a.function = function_val - - return a - - -@_concat_chunks.register -def _(a: ToolCall, b: ToolCall) -> ToolCall: - # Preserve name from either a or b, with preference for a - name_val = a.name or b.name - - # Concatenate arguments string - args_val = "" - if a.arguments is not None and b.arguments is not None: - args_val = a.arguments + b.arguments - else: - args_val = a.arguments or b.arguments - - # Set all properties - a.name = name_val - a.arguments = args_val - - return a - - -def concat_completion_chunks(chunks: list[Completion]) -> Completion: - """ - Accumulates all chunks returned from a streaming completion call into a `Completion` message. - This is useful when you stream responses from an LLM and want to keep track of the context (i.e. previous messages + current message). - - Args: - chunks: list of completion chunks returned from streamed completion - Returns: - Completion: same as type returned from non-streaming completion - - - - To implement `concat_completion_chunks` we first implement a binary `_concat_chunks` function for each - type. Using `singledispatch` to dispatch the call to the appropriate function based on the type of the first argument. - Each nested type is then concatenated. We can then use reduce to accumulate the entire stream into a single a - single `CompletionChunk`. Finally we convert the type to the appropriate non-streaming type `Completion` and return it. - """ - if not chunks: - raise ValueError("Cannot concatenate empty chunks list") - - chunks_copy = chunks.copy() - chunks_copy[0] = deepcopy(chunks_copy[0]) # _concat_chunks mutates first argument - accumulated_chunks = reduce(_concat_chunks, chunks_copy) - - data = accumulated_chunks.model_dump() - data["object"] = "chat.completion" - choices = data["choices"] - for choice in choices: - choice["message"] = choice.pop("delta") - - return Completion.model_validate(data) diff --git a/src/agentex/lib/utils/console.py b/src/agentex/lib/utils/console.py 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 7e310cd89..000000000 --- a/src/agentex/protocol/acp.py +++ /dev/null @@ -1,140 +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" - TASK_INTERRUPT = "task/interrupt" - - -class CreateTaskParams(BaseModel): - """Parameters for task/create method. - - Attributes: - agent: The agent that the task was sent to. - task: The task to be created. - params: The parameters for the task as inputted by the user. - request: Additional request context including headers forwarded to this agent. - """ - - agent: Agent = Field(..., description="The agent that the task was sent to") - task: Task = Field(..., description="The task to be created") - params: dict[str, Any] | None = Field( - None, - description="The parameters for the task as inputted by the user", - ) - request: dict[str, Any] | None = Field( - default=None, - description="Additional request context including headers forwarded to this agent", - ) - - -class SendMessageParams(BaseModel): - """Parameters for message/send method. - - Attributes: - agent: The agent that the message was sent to. - task: The task that the message was sent to. - content: The message that was sent to the agent. - stream: Whether to stream the message back to the agentex server from the agent. - request: Additional request context including headers forwarded to this agent. - """ - - agent: Agent = Field(..., description="The agent that the message was sent to") - task: Task = Field(..., description="The task that the message was sent to") - content: TaskMessageContent = Field( - ..., description="The message that was sent to the agent" - ) - stream: bool = Field( - False, - description="Whether to stream the message back to the agentex server from the agent", - ) - request: dict[str, Any] | None = Field( - default=None, - description="Additional request context including headers forwarded to this agent", - ) - - -class SendEventParams(BaseModel): - """Parameters for event/send method. - - Attributes: - agent: The agent that the event was sent to. - task: The task that the message was sent to. - event: The event that was sent to the agent. - request: Additional request context including headers forwarded to this agent. - """ - - agent: Agent = Field(..., description="The agent that the event was sent to") - task: Task = Field(..., description="The task that the message was sent to") - event: Event = Field(..., description="The event that was sent to the agent") - request: dict[str, Any] | None = Field( - default=None, - description="Additional request context including headers forwarded to this agent", - ) - - -class CancelTaskParams(BaseModel): - """Parameters for task/cancel method. - - Attributes: - agent: The agent that the task was sent to. - task: The task that was cancelled. - request: Additional request context including headers forwarded to this agent. - """ - - agent: Agent = Field(..., description="The agent that the task was sent to") - task: Task = Field(..., description="The task that was cancelled") - request: dict[str, Any] | None = Field( - default=None, - description="Additional request context including headers forwarded to this agent", - ) - - -class InterruptTaskParams(BaseModel): - """Parameters for task/interrupt method. - - Non-terminal counterpart to :class:`CancelTaskParams`. The control plane - forwards ``task/interrupt`` to the agent so it can stop the in-flight turn - while leaving the task continuable (status ``INTERRUPTED``, not a terminal - status). See the interrupt-and-queue design doc, sections 5-7. - - Attributes: - agent: The agent that the task was sent to. - task: The task that was interrupted. - request: Additional request context including headers forwarded to this agent. - """ - - agent: Agent = Field(..., description="The agent that the task was sent to") - task: Task = Field(..., description="The task that was interrupted") - request: dict[str, Any] | None = Field( - default=None, - description="Additional request context including headers forwarded to this agent", - ) - - -RPC_SYNC_METHODS = [ - RPCMethod.MESSAGE_SEND, -] - -PARAMS_MODEL_BY_METHOD: dict[RPCMethod, type[BaseModel]] = { - RPCMethod.EVENT_SEND: SendEventParams, - RPCMethod.TASK_CANCEL: CancelTaskParams, - RPCMethod.MESSAGE_SEND: SendMessageParams, - RPCMethod.TASK_CREATE: CreateTaskParams, - RPCMethod.TASK_INTERRUPT: InterruptTaskParams, -} diff --git a/src/agentex/protocol/json_rpc.py b/src/agentex/protocol/json_rpc.py 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 924fab3fb..0c55cca61 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 @@ -417,300 +408,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): @@ -1078,302 +775,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/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/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/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 c22829ac6..c26201e8f 100644 --- a/src/agentex/types/tool_response_content.py +++ b/src/agentex/types/tool_response_content.py @@ -32,11 +32,11 @@ class ToolResponseContent(BaseModel): `None` when the harness does not report a status. """ - 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 362d32f77..82b13647f 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, @@ -27,8 +28,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 501dcb662..76b8b919e 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 b5275201f..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") diff --git a/tests/api_resources/test_tasks.py b/tests/api_resources/test_tasks.py index 37df8f0f3..61e443aab 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/api_resources/test_webhooks.py b/tests/api_resources/test_webhooks.py index ff32dd719..24e269afc 100644 --- a/tests/api_resources/test_webhooks.py +++ b/tests/api_resources/test_webhooks.py @@ -8,10 +8,9 @@ import pytest from agentex import Agentex, AsyncAgentex +from tests.utils import assert_matches_type from agentex.types import WebhookCreateWebhookTriggerResponse -from ..utils import assert_matches_type - base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") diff --git a/tests/compat/__init__.py b/tests/compat/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/compat/refresh_specs.py b/tests/compat/refresh_specs.py deleted file mode 100644 index 396d4afa5..000000000 --- a/tests/compat/refresh_specs.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Re-vendor the server OpenAPI specs at the SHAs pinned in manifest.json. -Usage: `python tests/compat/refresh_specs.py` (needs `gh` auth for the source repo).""" - -from __future__ import annotations - -import json -import subprocess -from pathlib import Path - -_DIR = Path(__file__).parent / "server_specs" - - -def main() -> None: - manifest = json.loads((_DIR / "manifest.json").read_text()) - repo, path = manifest["source_repo"], manifest["source_path"] - for entry in manifest["specs"]: - content = subprocess.run( - [ - "gh", - "api", - f"repos/{repo}/contents/{path}?ref={entry['sha']}", - "-H", - "Accept: application/vnd.github.raw", - ], - check=True, - capture_output=True, - text=True, - ).stdout - (_DIR / entry["file"]).write_text(content) - print(f"wrote {entry['file']} from {repo}@{entry['sha'][:12]}") - - -if __name__ == "__main__": - main() diff --git a/tests/compat/server_specs/current.yaml b/tests/compat/server_specs/current.yaml deleted file mode 100644 index b122020e4..000000000 --- a/tests/compat/server_specs/current.yaml +++ /dev/null @@ -1,6741 +0,0 @@ -openapi: 3.1.0 -info: - title: Agentex API - version: 0.1.0 -paths: - /agents/{agent_id}: - get: - tags: - - Agents - summary: Get Agent by ID - description: Get an agent by its unique ID. - operationId: get_agent_by_id_agents__agent_id__get - parameters: - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Agent' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - delete: - tags: - - Agents - summary: Delete Agent by ID - description: Delete an agent by its unique ID. - operationId: delete_agent_by_id_agents__agent_id__delete - parameters: - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/name/{agent_name}: - get: - tags: - - Agents - summary: Get Agent by Name - description: Get an agent by its unique name. - operationId: get_agent_by_name_agents_name__agent_name__get - parameters: - - name: agent_name - in: path - required: true - schema: - type: string - title: Agent Name - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Agent' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - delete: - tags: - - Agents - summary: Delete Agent by Name - description: Delete an agent by its unique name. - operationId: delete_agent_by_name_agents_name__agent_name__delete - parameters: - - name: agent_name - in: path - required: true - schema: - type: string - title: Agent Name - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents: - get: - tags: - - Agents - summary: List Agents - description: List all registered agents, optionally filtered by query parameters. - operationId: list_agents_agents_get - parameters: - - name: task_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: Task ID - title: Task Id - description: Task ID - - name: limit - in: query - required: false - schema: - type: integer - minimum: 1 - description: Limit - default: 50 - title: Limit - description: Limit - - name: page_number - in: query - required: false - schema: - type: integer - minimum: 1 - description: Page number - default: 1 - title: Page Number - description: Page number - - name: order_by - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: Field to order by - title: Order By - description: Field to order by - - name: order_direction - in: query - required: false - schema: - type: string - description: Order direction (asc or desc) - default: desc - title: Order Direction - description: Order direction (asc or desc) - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/Agent' - title: Response List Agents Agents Get - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/register: - post: - tags: - - Agents - summary: Register Agent - description: Register a new agent or update an existing one. - operationId: register_agent_agents_register_post - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/RegisterAgentRequest' - required: true - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/RegisterAgentResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/register-build: - post: - tags: - - Agents - summary: Register Build - description: Register an agent at build time, before it is deployed, so it can - be permissioned and shared prior to deploy. Idempotent by name. - operationId: register_build_agents_register_build_post - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/RegisterBuildRequest' - required: true - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Agent' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/forward/name/{agent_name}/{path}: - get: - tags: - - Agents - summary: Forward GET request to agent by name - description: Forward a GET request to an agent by its name. - operationId: forward_get_request_to_agent_agents_forward_name__agent_name___path__get - parameters: - - name: agent_name - in: path - required: true - schema: - type: string - title: Agent Name - - name: path - in: path - required: true - schema: - type: string - title: Path - responses: - '200': - description: Successful Response - content: - application/json: - schema: {} - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - post: - tags: - - Agents - summary: Forward POST request to agent by name - description: Forward a POST request to an agent by its name. - operationId: forward_post_request_to_agent_agents_forward_name__agent_name___path__post - parameters: - - name: agent_name - in: path - required: true - schema: - type: string - title: Agent Name - - name: path - in: path - required: true - schema: - type: string - title: Path - responses: - '200': - description: Successful Response - content: - application/json: - schema: {} - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/{agent_id}/rpc: - post: - tags: - - Agents - summary: Handle Agent RPC by ID - description: Handle JSON-RPC requests for an agent by its unique ID. - operationId: handle_agent_rpc_by_id_agents__agent_id__rpc_post - parameters: - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/AgentRPCRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/AgentRPCResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/name/{agent_name}/rpc: - post: - tags: - - Agents - summary: Handle Agent RPC by Name - description: Handle JSON-RPC requests for an agent by its unique name. - operationId: handle_agent_rpc_by_name_agents_name__agent_name__rpc_post - parameters: - - name: agent_name - in: path - required: true - schema: - type: string - title: Agent Name - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/AgentRPCRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/AgentRPCResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/{task_id}: - get: - tags: - - Tasks - summary: Get Task by ID - description: Get a task by its unique ID. - operationId: get_task_tasks__task_id__get - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - - name: relationships - in: query - required: false - schema: - type: array - items: - $ref: '#/components/schemas/TaskRelationships' - title: Relationships - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/TaskResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - delete: - tags: - - Tasks - summary: Delete Task by ID - description: Delete a task by its unique ID. - operationId: delete_task_tasks__task_id__delete - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - put: - tags: - - Tasks - summary: Update Task by ID - description: Update mutable fields for a task by its unique ID. - operationId: update_task_tasks__task_id__put - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateTaskRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/name/{task_name}: - get: - tags: - - Tasks - summary: Get Task by Name - description: Get a task by its unique name. - operationId: get_task_by_name_tasks_name__task_name__get - parameters: - - name: task_name - in: path - required: true - schema: - type: string - title: Task Name - - name: relationships - in: query - required: false - schema: - type: array - items: - $ref: '#/components/schemas/TaskRelationships' - title: Relationships - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/TaskResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - delete: - tags: - - Tasks - summary: Delete Task by Name - description: Delete a task by its unique name. - operationId: delete_task_by_name_tasks_name__task_name__delete - parameters: - - name: task_name - in: path - required: true - schema: - type: string - title: Task Name - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - put: - tags: - - Tasks - summary: Update Task by Name - description: Update mutable fields for a task by its unique Name. - operationId: update_task_by_name_tasks_name__task_name__put - parameters: - - name: task_name - in: path - required: true - schema: - type: string - title: Task Name - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateTaskRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks: - get: - tags: - - Tasks - summary: List Tasks - description: List all tasks. - operationId: list_tasks_tasks_get - parameters: - - name: agent_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Agent Id - - name: agent_name - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Agent Name - - name: status - in: query - required: false - schema: - anyOf: - - $ref: '#/components/schemas/TaskStatus' - - type: 'null' - description: Filter tasks by status (e.g. RUNNING, COMPLETED). - title: Status - description: Filter tasks by status (e.g. RUNNING, COMPLETED). - - name: task_metadata - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: 'JSON-encoded object used to filter tasks via JSONB containment. - Example: {"created_by_user_id": "abc-123"}.' - title: Task Metadata - description: 'JSON-encoded object used to filter tasks via JSONB containment. - Example: {"created_by_user_id": "abc-123"}.' - - name: limit - in: query - required: false - schema: - type: integer - default: 50 - title: Limit - - name: page_number - in: query - required: false - schema: - type: integer - default: 1 - title: Page Number - - name: order_by - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Order By - - name: order_direction - in: query - required: false - schema: - type: string - default: desc - title: Order Direction - - name: relationships - in: query - required: false - schema: - type: array - items: - $ref: '#/components/schemas/TaskRelationships' - title: Relationships - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/TaskResponse' - title: Response List Tasks Tasks Get - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/{task_id}/complete: - post: - tags: - - Tasks - summary: Complete Task - description: Mark a running task as completed. - operationId: complete_task_tasks__task_id__complete_post - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - requestBody: - content: - application/json: - schema: - anyOf: - - $ref: '#/components/schemas/TaskStatusReasonRequest' - - type: 'null' - title: Request - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/{task_id}/fail: - post: - tags: - - Tasks - summary: Fail Task - description: Mark a running task as failed. - operationId: fail_task_tasks__task_id__fail_post - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - requestBody: - content: - application/json: - schema: - anyOf: - - $ref: '#/components/schemas/TaskStatusReasonRequest' - - type: 'null' - title: Request - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/{task_id}/cancel: - post: - tags: - - Tasks - summary: Cancel Task - description: Mark a running task as canceled. - operationId: cancel_task_tasks__task_id__cancel_post - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - requestBody: - content: - application/json: - schema: - anyOf: - - $ref: '#/components/schemas/TaskStatusReasonRequest' - - type: 'null' - title: Request - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/{task_id}/terminate: - post: - tags: - - Tasks - summary: Terminate Task - description: Mark a running task as terminated. - operationId: terminate_task_tasks__task_id__terminate_post - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - requestBody: - content: - application/json: - schema: - anyOf: - - $ref: '#/components/schemas/TaskStatusReasonRequest' - - type: 'null' - title: Request - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/{task_id}/timeout: - post: - tags: - - Tasks - summary: Timeout Task - description: Mark a running task as timed out. - operationId: timeout_task_tasks__task_id__timeout_post - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - requestBody: - content: - application/json: - schema: - anyOf: - - $ref: '#/components/schemas/TaskStatusReasonRequest' - - type: 'null' - title: Request - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/{task_id}/stream: - get: - tags: - - Tasks - summary: Stream Task Events by ID - description: Stream events for a task by its unique ID. - operationId: stream_task_events_tasks__task_id__stream_get - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: {} - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/name/{task_name}/stream: - get: - tags: - - Tasks - summary: Stream Task Events by Name - description: Stream events for a task by its unique name. - operationId: stream_task_events_by_name_tasks_name__task_name__stream_get - parameters: - - name: task_name - in: path - required: true - schema: - type: string - title: Task Name - responses: - '200': - description: Successful Response - content: - application/json: - schema: {} - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/{task_id}/query/{query_name}: - get: - tags: - - Tasks - summary: Query Task Workflow - description: Query a Temporal workflow associated with a task for its current - state. - operationId: query_task_workflow_tasks__task_id__query__query_name__get - parameters: - - name: query_name - in: path - required: true - schema: - type: string - title: Query Name - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: object - additionalProperties: true - title: Response Query Task Workflow Tasks Task Id Query Query Name Get - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /messages/batch: - put: - tags: - - Messages - summary: Batch Update Messages - operationId: batch_update_messages_messages_batch_put - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/BatchUpdateTaskMessagesRequest' - required: true - responses: - '200': - description: Successful Response - content: - application/json: - schema: - items: - $ref: '#/components/schemas/TaskMessage' - type: array - title: Response Batch Update Messages Messages Batch Put - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - post: - tags: - - Messages - summary: Batch Create Messages - operationId: batch_create_messages_messages_batch_post - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/BatchCreateTaskMessagesRequest' - required: true - responses: - '200': - description: Successful Response - content: - application/json: - schema: - items: - $ref: '#/components/schemas/TaskMessage' - type: array - title: Response Batch Create Messages Messages Batch Post - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /messages: - post: - tags: - - Messages - summary: Create Message - operationId: create_message_messages_post - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateTaskMessageRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/TaskMessage' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - get: - tags: - - Messages - summary: List Messages - description: 'List messages for a task with offset-based pagination. - - - For cursor-based pagination with infinite scroll support, use /messages/paginated.' - operationId: list_messages_messages_get - parameters: - - name: limit - in: query - required: false - schema: - type: integer - default: 50 - title: Limit - - name: page_number - in: query - required: false - schema: - type: integer - default: 1 - title: Page Number - - name: order_by - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Order By - - name: order_direction - in: query - required: false - schema: - type: string - default: desc - title: Order Direction - - name: filters - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: "JSON-encoded array of TaskMessageEntityFilter objects.\n\n\ - Schema: {\n \"$defs\": {\n \"DataContentEntityOptional\": {\n \ - \ \"properties\": {\n \"type\": {\n \"anyOf\": [\n \ - \ {\n \"const\": \"data\",\n \"type\"\ - : \"string\"\n },\n {\n \"type\": \"\ - null\"\n }\n ],\n \"default\": null,\n \ - \ \"description\": \"The type of the message, in this case `data`.\"\ - ,\n \"title\": \"Type\"\n },\n \"author\": {\n\ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The role of the messages author, in this case `system`,\ - \ `user`, `assistant`, or `tool`.\"\n },\n \"style\": {\n\ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The style of the message. This is used by the client\ - \ to determine how to display the message.\"\n },\n \"data\"\ - : {\n \"anyOf\": [\n {\n \"additionalProperties\"\ - : true,\n \"type\": \"object\"\n },\n \ - \ {\n \"type\": \"null\"\n }\n ],\n\ - \ \"default\": null,\n \"description\": \"The contents\ - \ of the data message.\",\n \"title\": \"Data\"\n }\n\ - \ },\n \"title\": \"DataContentEntityOptional\",\n \"type\"\ - : \"object\"\n },\n \"FileAttachmentEntity\": {\n \"description\"\ - : \"Represents a file attachment in messages.\",\n \"properties\"\ - : {\n \"file_id\": {\n \"description\": \"The unique ID\ - \ of the attached file\",\n \"title\": \"File Id\",\n \ - \ \"type\": \"string\"\n },\n \"name\": {\n \"\ - description\": \"The name of the file\",\n \"title\": \"Name\"\ - ,\n \"type\": \"string\"\n },\n \"size\": {\n \ - \ \"description\": \"The size of the file in bytes\",\n \ - \ \"title\": \"Size\",\n \"type\": \"integer\"\n },\n\ - \ \"type\": {\n \"description\": \"The MIME type or content\ - \ type of the file\",\n \"title\": \"Type\",\n \"type\"\ - : \"string\"\n }\n },\n \"required\": [\n \"file_id\"\ - ,\n \"name\",\n \"size\",\n \"type\"\n ],\n\ - \ \"title\": \"FileAttachmentEntity\",\n \"type\": \"object\"\ - \n },\n \"MessageAuthor\": {\n \"enum\": [\n \"user\"\ - ,\n \"agent\"\n ],\n \"title\": \"MessageAuthor\",\n\ - \ \"type\": \"string\"\n },\n \"MessageStyle\": {\n \"\ - enum\": [\n \"static\",\n \"active\"\n ],\n \"\ - title\": \"MessageStyle\",\n \"type\": \"string\"\n },\n \"\ - ReasoningContentEntityOptional\": {\n \"properties\": {\n \ - \ \"type\": {\n \"anyOf\": [\n {\n \"\ - const\": \"reasoning\",\n \"type\": \"string\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The type of the message, in this case `reasoning`.\",\n \"\ - title\": \"Type\"\n },\n \"author\": {\n \"anyOf\"\ - : [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\n\ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The role of the messages author, in this case `system`,\ - \ `user`, `assistant`, or `tool`.\"\n },\n \"style\": {\n\ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The style of the message. This is used by the client\ - \ to determine how to display the message.\"\n },\n \"summary\"\ - : {\n \"anyOf\": [\n {\n \"items\": {\n\ - \ \"type\": \"string\"\n },\n \ - \ \"type\": \"array\"\n },\n {\n \"\ - type\": \"null\"\n }\n ],\n \"default\":\ - \ null,\n \"description\": \"A list of short reasoning summaries\"\ - ,\n \"title\": \"Summary\"\n },\n \"content\":\ - \ {\n \"anyOf\": [\n {\n \"items\": {\n\ - \ \"type\": \"string\"\n },\n \ - \ \"type\": \"array\"\n },\n {\n \"\ - type\": \"null\"\n }\n ],\n \"default\":\ - \ null,\n \"description\": \"The reasoning content or chain-of-thought\ - \ text\",\n \"title\": \"Content\"\n }\n },\n \ - \ \"title\": \"ReasoningContentEntityOptional\",\n \"type\": \"\ - object\"\n },\n \"TextContentEntityOptional\": {\n \"properties\"\ - : {\n \"type\": {\n \"anyOf\": [\n {\n \ - \ \"const\": \"text\",\n \"type\": \"string\"\n\ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The type of the message, in this case `text`.\",\n \ - \ \"title\": \"Type\"\n },\n \"author\": {\n \ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The role of the messages author, in this case `system`,\ - \ `user`, `assistant`, or `tool`.\"\n },\n \"style\": {\n\ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The style of the message. This is used by the client\ - \ to determine how to display the message.\"\n },\n \"format\"\ - : {\n \"anyOf\": [\n {\n \"$ref\": \"\ - #/$defs/TextFormat\"\n },\n {\n \"\ - type\": \"null\"\n }\n ],\n \"default\":\ - \ null,\n \"description\": \"The format of the message. This\ - \ is used by the client to determine how to display the message.\"\n \ - \ },\n \"content\": {\n \"anyOf\": [\n \ - \ {\n \"type\": \"string\"\n },\n \ - \ {\n \"type\": \"null\"\n }\n ],\n\ - \ \"default\": null,\n \"description\": \"The contents\ - \ of the text message.\",\n \"title\": \"Content\"\n },\n\ - \ \"attachments\": {\n \"anyOf\": [\n {\n \ - \ \"items\": {\n \"$ref\": \"#/$defs/FileAttachmentEntity\"\ - \n },\n \"type\": \"array\"\n },\n\ - \ {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"\ - Optional list of file attachments with structured metadata.\",\n \ - \ \"title\": \"Attachments\"\n }\n },\n \"title\"\ - : \"TextContentEntityOptional\",\n \"type\": \"object\"\n },\n\ - \ \"TextFormat\": {\n \"enum\": [\n \"markdown\",\n \ - \ \"plain\",\n \"code\"\n ],\n \"title\": \"TextFormat\"\ - ,\n \"type\": \"string\"\n },\n \"ToolRequestContentEntityOptional\"\ - : {\n \"properties\": {\n \"type\": {\n \"anyOf\"\ - : [\n {\n \"const\": \"tool_request\",\n \ - \ \"type\": \"string\"\n },\n {\n \ - \ \"type\": \"null\"\n }\n ],\n \"\ - default\": null,\n \"description\": \"The type of the message,\ - \ in this case `tool_request`.\",\n \"title\": \"Type\"\n \ - \ },\n \"author\": {\n \"anyOf\": [\n {\n\ - \ \"$ref\": \"#/$defs/MessageAuthor\"\n },\n \ - \ {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"\ - The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\"\ - : [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\n\ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The style of the message. This is used by the client\ - \ to determine how to display the message.\"\n },\n \"tool_call_id\"\ - : {\n \"anyOf\": [\n {\n \"type\": \"\ - string\"\n },\n {\n \"type\": \"null\"\ - \n }\n ],\n \"default\": null,\n \ - \ \"description\": \"The ID of the tool call that is being requested.\"\ - ,\n \"title\": \"Tool Call Id\"\n },\n \"name\"\ - : {\n \"anyOf\": [\n {\n \"type\": \"\ - string\"\n },\n {\n \"type\": \"null\"\ - \n }\n ],\n \"default\": null,\n \ - \ \"description\": \"The name of the tool that is being requested.\"\ - ,\n \"title\": \"Name\"\n },\n \"arguments\": {\n\ - \ \"anyOf\": [\n {\n \"additionalProperties\"\ - : true,\n \"type\": \"object\"\n },\n \ - \ {\n \"type\": \"null\"\n }\n ],\n\ - \ \"default\": null,\n \"description\": \"The arguments\ - \ to the tool.\",\n \"title\": \"Arguments\"\n }\n \ - \ },\n \"title\": \"ToolRequestContentEntityOptional\",\n \ - \ \"type\": \"object\"\n },\n \"ToolResponseContentEntityOptional\"\ - : {\n \"properties\": {\n \"type\": {\n \"anyOf\"\ - : [\n {\n \"const\": \"tool_response\",\n \ - \ \"type\": \"string\"\n },\n {\n \ - \ \"type\": \"null\"\n }\n ],\n \"\ - default\": null,\n \"description\": \"The type of the message,\ - \ in this case `tool_response`.\",\n \"title\": \"Type\"\n \ - \ },\n \"author\": {\n \"anyOf\": [\n \ - \ {\n \"$ref\": \"#/$defs/MessageAuthor\"\n },\n\ - \ {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"\ - The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\"\ - : [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\n\ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The style of the message. This is used by the client\ - \ to determine how to display the message.\"\n },\n \"tool_call_id\"\ - : {\n \"anyOf\": [\n {\n \"type\": \"\ - string\"\n },\n {\n \"type\": \"null\"\ - \n }\n ],\n \"default\": null,\n \ - \ \"description\": \"The ID of the tool call that is being responded\ - \ to.\",\n \"title\": \"Tool Call Id\"\n },\n \"\ - name\": {\n \"anyOf\": [\n {\n \"type\"\ - : \"string\"\n },\n {\n \"type\": \"\ - null\"\n }\n ],\n \"default\": null,\n \ - \ \"description\": \"The name of the tool that is being responded\ - \ to.\",\n \"title\": \"Name\"\n },\n \"content\"\ - : {\n \"anyOf\": [\n {},\n {\n \ - \ \"type\": \"null\"\n }\n ],\n \"default\"\ - : null,\n \"description\": \"The result of the tool.\",\n \ - \ \"title\": \"Content\"\n }\n },\n \"title\":\ - \ \"ToolResponseContentEntityOptional\",\n \"type\": \"object\"\n\ - \ }\n },\n \"description\": \"Filter model for TaskMessage - all\ - \ fields optional for flexible filtering.\\n\\nThe `exclude` field determines\ - \ whether this filter is inclusionary or exclusionary.\\nWhen multiple\ - \ filters are provided:\\n- Inclusionary filters (exclude=False) are OR'd\ - \ together\\n- Exclusionary filters (exclude=True) are OR'd together and\ - \ negated with $nor\\n- The two groups are AND'd: (include1 OR include2)\ - \ AND NOT (exclude1 OR exclude2)\",\n \"properties\": {\n \"content\"\ - : {\n \"anyOf\": [\n {\n \"$ref\": \"#/$defs/ToolRequestContentEntityOptional\"\ - \n },\n {\n \"$ref\": \"#/$defs/DataContentEntityOptional\"\ - \n },\n {\n \"$ref\": \"#/$defs/TextContentEntityOptional\"\ - \n },\n {\n \"$ref\": \"#/$defs/ToolResponseContentEntityOptional\"\ - \n },\n {\n \"$ref\": \"#/$defs/ReasoningContentEntityOptional\"\ - \n },\n {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"Filter by message\ - \ content\",\n \"title\": \"Content\"\n },\n \"streaming_status\"\ - : {\n \"anyOf\": [\n {\n \"enum\": [\n \ - \ \"IN_PROGRESS\",\n \"DONE\"\n ],\n \"\ - type\": \"string\"\n },\n {\n \"type\": \"null\"\ - \n }\n ],\n \"default\": null,\n \"description\"\ - : \"Filter by streaming status\",\n \"title\": \"Streaming Status\"\ - \n },\n \"exclude\": {\n \"default\": false,\n \"description\"\ - : \"If true, this filter excludes matching messages\",\n \"title\"\ - : \"Exclude\",\n \"type\": \"boolean\"\n }\n },\n \"title\"\ - : \"TaskMessageEntityFilter\",\n \"type\": \"object\"\n}\n\nEach filter\ - \ can include:\n- `content`: Filter by message content (type, author,\ - \ data fields)\n- `streaming_status`: Filter by status (\"IN_PROGRESS\"\ - \ or \"DONE\")\n- `exclude`: If true, excludes matching messages (default:\ - \ false)\n\nMultiple filters are combined: inclusionary filters (exclude=false)\ - \ are OR'd together,\nexclusionary filters (exclude=true) are OR'd and\ - \ negated, then both groups are AND'd.\n" - title: Filters - description: "JSON-encoded array of TaskMessageEntityFilter objects.\n\nSchema:\ - \ {\n \"$defs\": {\n \"DataContentEntityOptional\": {\n \"properties\"\ - : {\n \"type\": {\n \"anyOf\": [\n {\n \ - \ \"const\": \"data\",\n \"type\": \"string\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The type of the message, in this case `data`.\",\n \"title\"\ - : \"Type\"\n },\n \"author\": {\n \"anyOf\": [\n\ - \ {\n \"$ref\": \"#/$defs/MessageAuthor\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ - \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The style of the message. This is used by the client to determine how\ - \ to display the message.\"\n },\n \"data\": {\n \ - \ \"anyOf\": [\n {\n \"additionalProperties\": true,\n\ - \ \"type\": \"object\"\n },\n {\n \ - \ \"type\": \"null\"\n }\n ],\n \"\ - default\": null,\n \"description\": \"The contents of the data\ - \ message.\",\n \"title\": \"Data\"\n }\n },\n \ - \ \"title\": \"DataContentEntityOptional\",\n \"type\": \"object\"\ - \n },\n \"FileAttachmentEntity\": {\n \"description\": \"Represents\ - \ a file attachment in messages.\",\n \"properties\": {\n \"\ - file_id\": {\n \"description\": \"The unique ID of the attached\ - \ file\",\n \"title\": \"File Id\",\n \"type\": \"string\"\ - \n },\n \"name\": {\n \"description\": \"The name\ - \ of the file\",\n \"title\": \"Name\",\n \"type\": \"\ - string\"\n },\n \"size\": {\n \"description\": \"\ - The size of the file in bytes\",\n \"title\": \"Size\",\n \ - \ \"type\": \"integer\"\n },\n \"type\": {\n \ - \ \"description\": \"The MIME type or content type of the file\",\n \ - \ \"title\": \"Type\",\n \"type\": \"string\"\n }\n\ - \ },\n \"required\": [\n \"file_id\",\n \"name\"\ - ,\n \"size\",\n \"type\"\n ],\n \"title\": \"FileAttachmentEntity\"\ - ,\n \"type\": \"object\"\n },\n \"MessageAuthor\": {\n \"\ - enum\": [\n \"user\",\n \"agent\"\n ],\n \"title\"\ - : \"MessageAuthor\",\n \"type\": \"string\"\n },\n \"MessageStyle\"\ - : {\n \"enum\": [\n \"static\",\n \"active\"\n ],\n\ - \ \"title\": \"MessageStyle\",\n \"type\": \"string\"\n },\n\ - \ \"ReasoningContentEntityOptional\": {\n \"properties\": {\n \ - \ \"type\": {\n \"anyOf\": [\n {\n \ - \ \"const\": \"reasoning\",\n \"type\": \"string\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The type of the message, in this case `reasoning`.\",\n \"\ - title\": \"Type\"\n },\n \"author\": {\n \"anyOf\"\ - : [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\n\ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ - \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The style of the message. This is used by the client to determine how\ - \ to display the message.\"\n },\n \"summary\": {\n \ - \ \"anyOf\": [\n {\n \"items\": {\n \ - \ \"type\": \"string\"\n },\n \"type\":\ - \ \"array\"\n },\n {\n \"type\": \"null\"\ - \n }\n ],\n \"default\": null,\n \"\ - description\": \"A list of short reasoning summaries\",\n \"title\"\ - : \"Summary\"\n },\n \"content\": {\n \"anyOf\":\ - \ [\n {\n \"items\": {\n \"type\"\ - : \"string\"\n },\n \"type\": \"array\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The reasoning content or chain-of-thought text\",\n \"title\"\ - : \"Content\"\n }\n },\n \"title\": \"ReasoningContentEntityOptional\"\ - ,\n \"type\": \"object\"\n },\n \"TextContentEntityOptional\"\ - : {\n \"properties\": {\n \"type\": {\n \"anyOf\":\ - \ [\n {\n \"const\": \"text\",\n \"\ - type\": \"string\"\n },\n {\n \"type\"\ - : \"null\"\n }\n ],\n \"default\": null,\n\ - \ \"description\": \"The type of the message, in this case `text`.\"\ - ,\n \"title\": \"Type\"\n },\n \"author\": {\n \ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ - \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The style of the message. This is used by the client to determine how\ - \ to display the message.\"\n },\n \"format\": {\n \ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/TextFormat\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The format of the message. This is used by the client to determine how\ - \ to display the message.\"\n },\n \"content\": {\n \ - \ \"anyOf\": [\n {\n \"type\": \"string\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The contents of the text message.\",\n \"title\": \"Content\"\ - \n },\n \"attachments\": {\n \"anyOf\": [\n \ - \ {\n \"items\": {\n \"$ref\": \"#/$defs/FileAttachmentEntity\"\ - \n },\n \"type\": \"array\"\n },\n\ - \ {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"Optional\ - \ list of file attachments with structured metadata.\",\n \"title\"\ - : \"Attachments\"\n }\n },\n \"title\": \"TextContentEntityOptional\"\ - ,\n \"type\": \"object\"\n },\n \"TextFormat\": {\n \"enum\"\ - : [\n \"markdown\",\n \"plain\",\n \"code\"\n \ - \ ],\n \"title\": \"TextFormat\",\n \"type\": \"string\"\n \ - \ },\n \"ToolRequestContentEntityOptional\": {\n \"properties\"\ - : {\n \"type\": {\n \"anyOf\": [\n {\n \ - \ \"const\": \"tool_request\",\n \"type\": \"string\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The type of the message, in this case `tool_request`.\",\n \ - \ \"title\": \"Type\"\n },\n \"author\": {\n \"anyOf\"\ - : [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\n\ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ - \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The style of the message. This is used by the client to determine how\ - \ to display the message.\"\n },\n \"tool_call_id\": {\n \ - \ \"anyOf\": [\n {\n \"type\": \"string\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The ID of the tool call that is being requested.\",\n \"title\"\ - : \"Tool Call Id\"\n },\n \"name\": {\n \"anyOf\"\ - : [\n {\n \"type\": \"string\"\n },\n\ - \ {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"The\ - \ name of the tool that is being requested.\",\n \"title\": \"\ - Name\"\n },\n \"arguments\": {\n \"anyOf\": [\n \ - \ {\n \"additionalProperties\": true,\n \ - \ \"type\": \"object\"\n },\n {\n \ - \ \"type\": \"null\"\n }\n ],\n \"default\"\ - : null,\n \"description\": \"The arguments to the tool.\",\n \ - \ \"title\": \"Arguments\"\n }\n },\n \"title\"\ - : \"ToolRequestContentEntityOptional\",\n \"type\": \"object\"\n \ - \ },\n \"ToolResponseContentEntityOptional\": {\n \"properties\"\ - : {\n \"type\": {\n \"anyOf\": [\n {\n \ - \ \"const\": \"tool_response\",\n \"type\": \"string\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The type of the message, in this case `tool_response`.\",\n \ - \ \"title\": \"Type\"\n },\n \"author\": {\n \"\ - anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ - \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The style of the message. This is used by the client to determine how\ - \ to display the message.\"\n },\n \"tool_call_id\": {\n \ - \ \"anyOf\": [\n {\n \"type\": \"string\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The ID of the tool call that is being responded to.\",\n \"\ - title\": \"Tool Call Id\"\n },\n \"name\": {\n \"\ - anyOf\": [\n {\n \"type\": \"string\"\n \ - \ },\n {\n \"type\": \"null\"\n }\n\ - \ ],\n \"default\": null,\n \"description\":\ - \ \"The name of the tool that is being responded to.\",\n \"title\"\ - : \"Name\"\n },\n \"content\": {\n \"anyOf\": [\n\ - \ {},\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The result of the tool.\",\n \"title\": \"Content\"\n \ - \ }\n },\n \"title\": \"ToolResponseContentEntityOptional\"\ - ,\n \"type\": \"object\"\n }\n },\n \"description\": \"Filter\ - \ model for TaskMessage - all fields optional for flexible filtering.\\\ - n\\nThe `exclude` field determines whether this filter is inclusionary or\ - \ exclusionary.\\nWhen multiple filters are provided:\\n- Inclusionary filters\ - \ (exclude=False) are OR'd together\\n- Exclusionary filters (exclude=True)\ - \ are OR'd together and negated with $nor\\n- The two groups are AND'd:\ - \ (include1 OR include2) AND NOT (exclude1 OR exclude2)\",\n \"properties\"\ - : {\n \"content\": {\n \"anyOf\": [\n {\n \"$ref\"\ - : \"#/$defs/ToolRequestContentEntityOptional\"\n },\n {\n\ - \ \"$ref\": \"#/$defs/DataContentEntityOptional\"\n },\n\ - \ {\n \"$ref\": \"#/$defs/TextContentEntityOptional\"\n\ - \ },\n {\n \"$ref\": \"#/$defs/ToolResponseContentEntityOptional\"\ - \n },\n {\n \"$ref\": \"#/$defs/ReasoningContentEntityOptional\"\ - \n },\n {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"Filter by message\ - \ content\",\n \"title\": \"Content\"\n },\n \"streaming_status\"\ - : {\n \"anyOf\": [\n {\n \"enum\": [\n \"\ - IN_PROGRESS\",\n \"DONE\"\n ],\n \"type\":\ - \ \"string\"\n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\": \"Filter\ - \ by streaming status\",\n \"title\": \"Streaming Status\"\n },\n\ - \ \"exclude\": {\n \"default\": false,\n \"description\": \"\ - If true, this filter excludes matching messages\",\n \"title\": \"\ - Exclude\",\n \"type\": \"boolean\"\n }\n },\n \"title\": \"TaskMessageEntityFilter\"\ - ,\n \"type\": \"object\"\n}\n\nEach filter can include:\n- `content`: Filter\ - \ by message content (type, author, data fields)\n- `streaming_status`:\ - \ Filter by status (\"IN_PROGRESS\" or \"DONE\")\n- `exclude`: If true,\ - \ excludes matching messages (default: false)\n\nMultiple filters are combined:\ - \ inclusionary filters (exclude=false) are OR'd together,\nexclusionary\ - \ filters (exclude=true) are OR'd and negated, then both groups are AND'd.\n" - examples: - single_filter: - summary: Filter by content type - value: '{"content": {"type": "text"}}' - multiple_types: - summary: Filter multiple content types (OR) - value: '[{"content": {"type": "text"}}, {"content": {"type": "data"}}]' - with_exclusion: - summary: Include data messages, exclude specific data types - value: '[{"content": {"type": "data"}}, {"content": {"data": {"type": - "error_report"}}, "exclude": true}]' - nested_data: - summary: Filter by nested data field - value: '{"content": {"data": {"type": "report_status_update"}}}' - - name: task_id - in: query - required: true - schema: - type: string - description: The task ID - title: Task Id - description: The task ID - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/TaskMessage' - title: Response List Messages Messages Get - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /messages/{message_id}: - put: - tags: - - Messages - summary: Update Message - operationId: update_message_messages__message_id__put - parameters: - - name: message_id - in: path - required: true - schema: - type: string - title: Message Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateTaskMessageRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/TaskMessage' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - get: - tags: - - Messages - summary: Get Message - operationId: get_message_messages__message_id__get - parameters: - - name: message_id - in: path - required: true - schema: - type: string - title: Message Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/TaskMessage' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /messages/paginated: - get: - tags: - - Messages - summary: List Messages Paginated - description: "List messages for a task with cursor-based pagination.\n\nThis\ - \ endpoint is designed for infinite scroll UIs where new messages may arrive\n\ - while paginating through older ones.\n\nArgs:\n task_id: The task ID to\ - \ filter messages by\n limit: Maximum number of messages to return (default:\ - \ 50)\n cursor: Opaque cursor string for pagination. Pass the `next_cursor`\ - \ from\n a previous response to get the next page.\n direction:\ - \ Pagination direction - \"older\" to get older messages (default),\n \ - \ \"newer\" to get newer messages.\n\nReturns:\n PaginatedMessagesResponse\ - \ with:\n - data: List of messages (newest first when direction=\"older\"\ - )\n - next_cursor: Cursor for fetching the next page (null if no more pages)\n\ - \ - has_more: Whether there are more messages to fetch\n\nExample:\n \ - \ First request: GET /messages/paginated?task_id=xxx&limit=50\n Next page:\ - \ GET /messages/paginated?task_id=xxx&limit=50&cursor=" - operationId: list_messages_paginated_messages_paginated_get - parameters: - - name: limit - in: query - required: false - schema: - type: integer - default: 50 - title: Limit - - name: cursor - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Cursor - - name: direction - in: query - required: false - schema: - enum: - - older - - newer - type: string - default: older - title: Direction - - name: filters - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: "JSON-encoded array of TaskMessageEntityFilter objects.\n\n\ - Schema: {\n \"$defs\": {\n \"DataContentEntityOptional\": {\n \ - \ \"properties\": {\n \"type\": {\n \"anyOf\": [\n \ - \ {\n \"const\": \"data\",\n \"type\"\ - : \"string\"\n },\n {\n \"type\": \"\ - null\"\n }\n ],\n \"default\": null,\n \ - \ \"description\": \"The type of the message, in this case `data`.\"\ - ,\n \"title\": \"Type\"\n },\n \"author\": {\n\ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The role of the messages author, in this case `system`,\ - \ `user`, `assistant`, or `tool`.\"\n },\n \"style\": {\n\ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The style of the message. This is used by the client\ - \ to determine how to display the message.\"\n },\n \"data\"\ - : {\n \"anyOf\": [\n {\n \"additionalProperties\"\ - : true,\n \"type\": \"object\"\n },\n \ - \ {\n \"type\": \"null\"\n }\n ],\n\ - \ \"default\": null,\n \"description\": \"The contents\ - \ of the data message.\",\n \"title\": \"Data\"\n }\n\ - \ },\n \"title\": \"DataContentEntityOptional\",\n \"type\"\ - : \"object\"\n },\n \"FileAttachmentEntity\": {\n \"description\"\ - : \"Represents a file attachment in messages.\",\n \"properties\"\ - : {\n \"file_id\": {\n \"description\": \"The unique ID\ - \ of the attached file\",\n \"title\": \"File Id\",\n \ - \ \"type\": \"string\"\n },\n \"name\": {\n \"\ - description\": \"The name of the file\",\n \"title\": \"Name\"\ - ,\n \"type\": \"string\"\n },\n \"size\": {\n \ - \ \"description\": \"The size of the file in bytes\",\n \ - \ \"title\": \"Size\",\n \"type\": \"integer\"\n },\n\ - \ \"type\": {\n \"description\": \"The MIME type or content\ - \ type of the file\",\n \"title\": \"Type\",\n \"type\"\ - : \"string\"\n }\n },\n \"required\": [\n \"file_id\"\ - ,\n \"name\",\n \"size\",\n \"type\"\n ],\n\ - \ \"title\": \"FileAttachmentEntity\",\n \"type\": \"object\"\ - \n },\n \"MessageAuthor\": {\n \"enum\": [\n \"user\"\ - ,\n \"agent\"\n ],\n \"title\": \"MessageAuthor\",\n\ - \ \"type\": \"string\"\n },\n \"MessageStyle\": {\n \"\ - enum\": [\n \"static\",\n \"active\"\n ],\n \"\ - title\": \"MessageStyle\",\n \"type\": \"string\"\n },\n \"\ - ReasoningContentEntityOptional\": {\n \"properties\": {\n \ - \ \"type\": {\n \"anyOf\": [\n {\n \"\ - const\": \"reasoning\",\n \"type\": \"string\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The type of the message, in this case `reasoning`.\",\n \"\ - title\": \"Type\"\n },\n \"author\": {\n \"anyOf\"\ - : [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\n\ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The role of the messages author, in this case `system`,\ - \ `user`, `assistant`, or `tool`.\"\n },\n \"style\": {\n\ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The style of the message. This is used by the client\ - \ to determine how to display the message.\"\n },\n \"summary\"\ - : {\n \"anyOf\": [\n {\n \"items\": {\n\ - \ \"type\": \"string\"\n },\n \ - \ \"type\": \"array\"\n },\n {\n \"\ - type\": \"null\"\n }\n ],\n \"default\":\ - \ null,\n \"description\": \"A list of short reasoning summaries\"\ - ,\n \"title\": \"Summary\"\n },\n \"content\":\ - \ {\n \"anyOf\": [\n {\n \"items\": {\n\ - \ \"type\": \"string\"\n },\n \ - \ \"type\": \"array\"\n },\n {\n \"\ - type\": \"null\"\n }\n ],\n \"default\":\ - \ null,\n \"description\": \"The reasoning content or chain-of-thought\ - \ text\",\n \"title\": \"Content\"\n }\n },\n \ - \ \"title\": \"ReasoningContentEntityOptional\",\n \"type\": \"\ - object\"\n },\n \"TextContentEntityOptional\": {\n \"properties\"\ - : {\n \"type\": {\n \"anyOf\": [\n {\n \ - \ \"const\": \"text\",\n \"type\": \"string\"\n\ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The type of the message, in this case `text`.\",\n \ - \ \"title\": \"Type\"\n },\n \"author\": {\n \ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The role of the messages author, in this case `system`,\ - \ `user`, `assistant`, or `tool`.\"\n },\n \"style\": {\n\ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The style of the message. This is used by the client\ - \ to determine how to display the message.\"\n },\n \"format\"\ - : {\n \"anyOf\": [\n {\n \"$ref\": \"\ - #/$defs/TextFormat\"\n },\n {\n \"\ - type\": \"null\"\n }\n ],\n \"default\":\ - \ null,\n \"description\": \"The format of the message. This\ - \ is used by the client to determine how to display the message.\"\n \ - \ },\n \"content\": {\n \"anyOf\": [\n \ - \ {\n \"type\": \"string\"\n },\n \ - \ {\n \"type\": \"null\"\n }\n ],\n\ - \ \"default\": null,\n \"description\": \"The contents\ - \ of the text message.\",\n \"title\": \"Content\"\n },\n\ - \ \"attachments\": {\n \"anyOf\": [\n {\n \ - \ \"items\": {\n \"$ref\": \"#/$defs/FileAttachmentEntity\"\ - \n },\n \"type\": \"array\"\n },\n\ - \ {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"\ - Optional list of file attachments with structured metadata.\",\n \ - \ \"title\": \"Attachments\"\n }\n },\n \"title\"\ - : \"TextContentEntityOptional\",\n \"type\": \"object\"\n },\n\ - \ \"TextFormat\": {\n \"enum\": [\n \"markdown\",\n \ - \ \"plain\",\n \"code\"\n ],\n \"title\": \"TextFormat\"\ - ,\n \"type\": \"string\"\n },\n \"ToolRequestContentEntityOptional\"\ - : {\n \"properties\": {\n \"type\": {\n \"anyOf\"\ - : [\n {\n \"const\": \"tool_request\",\n \ - \ \"type\": \"string\"\n },\n {\n \ - \ \"type\": \"null\"\n }\n ],\n \"\ - default\": null,\n \"description\": \"The type of the message,\ - \ in this case `tool_request`.\",\n \"title\": \"Type\"\n \ - \ },\n \"author\": {\n \"anyOf\": [\n {\n\ - \ \"$ref\": \"#/$defs/MessageAuthor\"\n },\n \ - \ {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"\ - The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\"\ - : [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\n\ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The style of the message. This is used by the client\ - \ to determine how to display the message.\"\n },\n \"tool_call_id\"\ - : {\n \"anyOf\": [\n {\n \"type\": \"\ - string\"\n },\n {\n \"type\": \"null\"\ - \n }\n ],\n \"default\": null,\n \ - \ \"description\": \"The ID of the tool call that is being requested.\"\ - ,\n \"title\": \"Tool Call Id\"\n },\n \"name\"\ - : {\n \"anyOf\": [\n {\n \"type\": \"\ - string\"\n },\n {\n \"type\": \"null\"\ - \n }\n ],\n \"default\": null,\n \ - \ \"description\": \"The name of the tool that is being requested.\"\ - ,\n \"title\": \"Name\"\n },\n \"arguments\": {\n\ - \ \"anyOf\": [\n {\n \"additionalProperties\"\ - : true,\n \"type\": \"object\"\n },\n \ - \ {\n \"type\": \"null\"\n }\n ],\n\ - \ \"default\": null,\n \"description\": \"The arguments\ - \ to the tool.\",\n \"title\": \"Arguments\"\n }\n \ - \ },\n \"title\": \"ToolRequestContentEntityOptional\",\n \ - \ \"type\": \"object\"\n },\n \"ToolResponseContentEntityOptional\"\ - : {\n \"properties\": {\n \"type\": {\n \"anyOf\"\ - : [\n {\n \"const\": \"tool_response\",\n \ - \ \"type\": \"string\"\n },\n {\n \ - \ \"type\": \"null\"\n }\n ],\n \"\ - default\": null,\n \"description\": \"The type of the message,\ - \ in this case `tool_response`.\",\n \"title\": \"Type\"\n \ - \ },\n \"author\": {\n \"anyOf\": [\n \ - \ {\n \"$ref\": \"#/$defs/MessageAuthor\"\n },\n\ - \ {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"\ - The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\"\ - : [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\n\ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The style of the message. This is used by the client\ - \ to determine how to display the message.\"\n },\n \"tool_call_id\"\ - : {\n \"anyOf\": [\n {\n \"type\": \"\ - string\"\n },\n {\n \"type\": \"null\"\ - \n }\n ],\n \"default\": null,\n \ - \ \"description\": \"The ID of the tool call that is being responded\ - \ to.\",\n \"title\": \"Tool Call Id\"\n },\n \"\ - name\": {\n \"anyOf\": [\n {\n \"type\"\ - : \"string\"\n },\n {\n \"type\": \"\ - null\"\n }\n ],\n \"default\": null,\n \ - \ \"description\": \"The name of the tool that is being responded\ - \ to.\",\n \"title\": \"Name\"\n },\n \"content\"\ - : {\n \"anyOf\": [\n {},\n {\n \ - \ \"type\": \"null\"\n }\n ],\n \"default\"\ - : null,\n \"description\": \"The result of the tool.\",\n \ - \ \"title\": \"Content\"\n }\n },\n \"title\":\ - \ \"ToolResponseContentEntityOptional\",\n \"type\": \"object\"\n\ - \ }\n },\n \"description\": \"Filter model for TaskMessage - all\ - \ fields optional for flexible filtering.\\n\\nThe `exclude` field determines\ - \ whether this filter is inclusionary or exclusionary.\\nWhen multiple\ - \ filters are provided:\\n- Inclusionary filters (exclude=False) are OR'd\ - \ together\\n- Exclusionary filters (exclude=True) are OR'd together and\ - \ negated with $nor\\n- The two groups are AND'd: (include1 OR include2)\ - \ AND NOT (exclude1 OR exclude2)\",\n \"properties\": {\n \"content\"\ - : {\n \"anyOf\": [\n {\n \"$ref\": \"#/$defs/ToolRequestContentEntityOptional\"\ - \n },\n {\n \"$ref\": \"#/$defs/DataContentEntityOptional\"\ - \n },\n {\n \"$ref\": \"#/$defs/TextContentEntityOptional\"\ - \n },\n {\n \"$ref\": \"#/$defs/ToolResponseContentEntityOptional\"\ - \n },\n {\n \"$ref\": \"#/$defs/ReasoningContentEntityOptional\"\ - \n },\n {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"Filter by message\ - \ content\",\n \"title\": \"Content\"\n },\n \"streaming_status\"\ - : {\n \"anyOf\": [\n {\n \"enum\": [\n \ - \ \"IN_PROGRESS\",\n \"DONE\"\n ],\n \"\ - type\": \"string\"\n },\n {\n \"type\": \"null\"\ - \n }\n ],\n \"default\": null,\n \"description\"\ - : \"Filter by streaming status\",\n \"title\": \"Streaming Status\"\ - \n },\n \"exclude\": {\n \"default\": false,\n \"description\"\ - : \"If true, this filter excludes matching messages\",\n \"title\"\ - : \"Exclude\",\n \"type\": \"boolean\"\n }\n },\n \"title\"\ - : \"TaskMessageEntityFilter\",\n \"type\": \"object\"\n}\n\nEach filter\ - \ can include:\n- `content`: Filter by message content (type, author,\ - \ data fields)\n- `streaming_status`: Filter by status (\"IN_PROGRESS\"\ - \ or \"DONE\")\n- `exclude`: If true, excludes matching messages (default:\ - \ false)\n\nMultiple filters are combined: inclusionary filters (exclude=false)\ - \ are OR'd together,\nexclusionary filters (exclude=true) are OR'd and\ - \ negated, then both groups are AND'd.\n" - title: Filters - description: "JSON-encoded array of TaskMessageEntityFilter objects.\n\nSchema:\ - \ {\n \"$defs\": {\n \"DataContentEntityOptional\": {\n \"properties\"\ - : {\n \"type\": {\n \"anyOf\": [\n {\n \ - \ \"const\": \"data\",\n \"type\": \"string\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The type of the message, in this case `data`.\",\n \"title\"\ - : \"Type\"\n },\n \"author\": {\n \"anyOf\": [\n\ - \ {\n \"$ref\": \"#/$defs/MessageAuthor\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ - \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The style of the message. This is used by the client to determine how\ - \ to display the message.\"\n },\n \"data\": {\n \ - \ \"anyOf\": [\n {\n \"additionalProperties\": true,\n\ - \ \"type\": \"object\"\n },\n {\n \ - \ \"type\": \"null\"\n }\n ],\n \"\ - default\": null,\n \"description\": \"The contents of the data\ - \ message.\",\n \"title\": \"Data\"\n }\n },\n \ - \ \"title\": \"DataContentEntityOptional\",\n \"type\": \"object\"\ - \n },\n \"FileAttachmentEntity\": {\n \"description\": \"Represents\ - \ a file attachment in messages.\",\n \"properties\": {\n \"\ - file_id\": {\n \"description\": \"The unique ID of the attached\ - \ file\",\n \"title\": \"File Id\",\n \"type\": \"string\"\ - \n },\n \"name\": {\n \"description\": \"The name\ - \ of the file\",\n \"title\": \"Name\",\n \"type\": \"\ - string\"\n },\n \"size\": {\n \"description\": \"\ - The size of the file in bytes\",\n \"title\": \"Size\",\n \ - \ \"type\": \"integer\"\n },\n \"type\": {\n \ - \ \"description\": \"The MIME type or content type of the file\",\n \ - \ \"title\": \"Type\",\n \"type\": \"string\"\n }\n\ - \ },\n \"required\": [\n \"file_id\",\n \"name\"\ - ,\n \"size\",\n \"type\"\n ],\n \"title\": \"FileAttachmentEntity\"\ - ,\n \"type\": \"object\"\n },\n \"MessageAuthor\": {\n \"\ - enum\": [\n \"user\",\n \"agent\"\n ],\n \"title\"\ - : \"MessageAuthor\",\n \"type\": \"string\"\n },\n \"MessageStyle\"\ - : {\n \"enum\": [\n \"static\",\n \"active\"\n ],\n\ - \ \"title\": \"MessageStyle\",\n \"type\": \"string\"\n },\n\ - \ \"ReasoningContentEntityOptional\": {\n \"properties\": {\n \ - \ \"type\": {\n \"anyOf\": [\n {\n \ - \ \"const\": \"reasoning\",\n \"type\": \"string\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The type of the message, in this case `reasoning`.\",\n \"\ - title\": \"Type\"\n },\n \"author\": {\n \"anyOf\"\ - : [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\n\ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ - \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The style of the message. This is used by the client to determine how\ - \ to display the message.\"\n },\n \"summary\": {\n \ - \ \"anyOf\": [\n {\n \"items\": {\n \ - \ \"type\": \"string\"\n },\n \"type\":\ - \ \"array\"\n },\n {\n \"type\": \"null\"\ - \n }\n ],\n \"default\": null,\n \"\ - description\": \"A list of short reasoning summaries\",\n \"title\"\ - : \"Summary\"\n },\n \"content\": {\n \"anyOf\":\ - \ [\n {\n \"items\": {\n \"type\"\ - : \"string\"\n },\n \"type\": \"array\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The reasoning content or chain-of-thought text\",\n \"title\"\ - : \"Content\"\n }\n },\n \"title\": \"ReasoningContentEntityOptional\"\ - ,\n \"type\": \"object\"\n },\n \"TextContentEntityOptional\"\ - : {\n \"properties\": {\n \"type\": {\n \"anyOf\":\ - \ [\n {\n \"const\": \"text\",\n \"\ - type\": \"string\"\n },\n {\n \"type\"\ - : \"null\"\n }\n ],\n \"default\": null,\n\ - \ \"description\": \"The type of the message, in this case `text`.\"\ - ,\n \"title\": \"Type\"\n },\n \"author\": {\n \ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ - \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The style of the message. This is used by the client to determine how\ - \ to display the message.\"\n },\n \"format\": {\n \ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/TextFormat\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The format of the message. This is used by the client to determine how\ - \ to display the message.\"\n },\n \"content\": {\n \ - \ \"anyOf\": [\n {\n \"type\": \"string\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The contents of the text message.\",\n \"title\": \"Content\"\ - \n },\n \"attachments\": {\n \"anyOf\": [\n \ - \ {\n \"items\": {\n \"$ref\": \"#/$defs/FileAttachmentEntity\"\ - \n },\n \"type\": \"array\"\n },\n\ - \ {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"Optional\ - \ list of file attachments with structured metadata.\",\n \"title\"\ - : \"Attachments\"\n }\n },\n \"title\": \"TextContentEntityOptional\"\ - ,\n \"type\": \"object\"\n },\n \"TextFormat\": {\n \"enum\"\ - : [\n \"markdown\",\n \"plain\",\n \"code\"\n \ - \ ],\n \"title\": \"TextFormat\",\n \"type\": \"string\"\n \ - \ },\n \"ToolRequestContentEntityOptional\": {\n \"properties\"\ - : {\n \"type\": {\n \"anyOf\": [\n {\n \ - \ \"const\": \"tool_request\",\n \"type\": \"string\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The type of the message, in this case `tool_request`.\",\n \ - \ \"title\": \"Type\"\n },\n \"author\": {\n \"anyOf\"\ - : [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\n\ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ - \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The style of the message. This is used by the client to determine how\ - \ to display the message.\"\n },\n \"tool_call_id\": {\n \ - \ \"anyOf\": [\n {\n \"type\": \"string\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The ID of the tool call that is being requested.\",\n \"title\"\ - : \"Tool Call Id\"\n },\n \"name\": {\n \"anyOf\"\ - : [\n {\n \"type\": \"string\"\n },\n\ - \ {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"The\ - \ name of the tool that is being requested.\",\n \"title\": \"\ - Name\"\n },\n \"arguments\": {\n \"anyOf\": [\n \ - \ {\n \"additionalProperties\": true,\n \ - \ \"type\": \"object\"\n },\n {\n \ - \ \"type\": \"null\"\n }\n ],\n \"default\"\ - : null,\n \"description\": \"The arguments to the tool.\",\n \ - \ \"title\": \"Arguments\"\n }\n },\n \"title\"\ - : \"ToolRequestContentEntityOptional\",\n \"type\": \"object\"\n \ - \ },\n \"ToolResponseContentEntityOptional\": {\n \"properties\"\ - : {\n \"type\": {\n \"anyOf\": [\n {\n \ - \ \"const\": \"tool_response\",\n \"type\": \"string\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The type of the message, in this case `tool_response`.\",\n \ - \ \"title\": \"Type\"\n },\n \"author\": {\n \"\ - anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ - \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The style of the message. This is used by the client to determine how\ - \ to display the message.\"\n },\n \"tool_call_id\": {\n \ - \ \"anyOf\": [\n {\n \"type\": \"string\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The ID of the tool call that is being responded to.\",\n \"\ - title\": \"Tool Call Id\"\n },\n \"name\": {\n \"\ - anyOf\": [\n {\n \"type\": \"string\"\n \ - \ },\n {\n \"type\": \"null\"\n }\n\ - \ ],\n \"default\": null,\n \"description\":\ - \ \"The name of the tool that is being responded to.\",\n \"title\"\ - : \"Name\"\n },\n \"content\": {\n \"anyOf\": [\n\ - \ {},\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The result of the tool.\",\n \"title\": \"Content\"\n \ - \ }\n },\n \"title\": \"ToolResponseContentEntityOptional\"\ - ,\n \"type\": \"object\"\n }\n },\n \"description\": \"Filter\ - \ model for TaskMessage - all fields optional for flexible filtering.\\\ - n\\nThe `exclude` field determines whether this filter is inclusionary or\ - \ exclusionary.\\nWhen multiple filters are provided:\\n- Inclusionary filters\ - \ (exclude=False) are OR'd together\\n- Exclusionary filters (exclude=True)\ - \ are OR'd together and negated with $nor\\n- The two groups are AND'd:\ - \ (include1 OR include2) AND NOT (exclude1 OR exclude2)\",\n \"properties\"\ - : {\n \"content\": {\n \"anyOf\": [\n {\n \"$ref\"\ - : \"#/$defs/ToolRequestContentEntityOptional\"\n },\n {\n\ - \ \"$ref\": \"#/$defs/DataContentEntityOptional\"\n },\n\ - \ {\n \"$ref\": \"#/$defs/TextContentEntityOptional\"\n\ - \ },\n {\n \"$ref\": \"#/$defs/ToolResponseContentEntityOptional\"\ - \n },\n {\n \"$ref\": \"#/$defs/ReasoningContentEntityOptional\"\ - \n },\n {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"Filter by message\ - \ content\",\n \"title\": \"Content\"\n },\n \"streaming_status\"\ - : {\n \"anyOf\": [\n {\n \"enum\": [\n \"\ - IN_PROGRESS\",\n \"DONE\"\n ],\n \"type\":\ - \ \"string\"\n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\": \"Filter\ - \ by streaming status\",\n \"title\": \"Streaming Status\"\n },\n\ - \ \"exclude\": {\n \"default\": false,\n \"description\": \"\ - If true, this filter excludes matching messages\",\n \"title\": \"\ - Exclude\",\n \"type\": \"boolean\"\n }\n },\n \"title\": \"TaskMessageEntityFilter\"\ - ,\n \"type\": \"object\"\n}\n\nEach filter can include:\n- `content`: Filter\ - \ by message content (type, author, data fields)\n- `streaming_status`:\ - \ Filter by status (\"IN_PROGRESS\" or \"DONE\")\n- `exclude`: If true,\ - \ excludes matching messages (default: false)\n\nMultiple filters are combined:\ - \ inclusionary filters (exclude=false) are OR'd together,\nexclusionary\ - \ filters (exclude=true) are OR'd and negated, then both groups are AND'd.\n" - examples: - single_filter: - summary: Filter by content type - value: '{"content": {"type": "text"}}' - multiple_types: - summary: Filter multiple content types (OR) - value: '[{"content": {"type": "text"}}, {"content": {"type": "data"}}]' - with_exclusion: - summary: Include data messages, exclude specific data types - value: '[{"content": {"type": "data"}}, {"content": {"data": {"type": - "error_report"}}, "exclude": true}]' - nested_data: - summary: Filter by nested data field - value: '{"content": {"data": {"type": "report_status_update"}}}' - - name: task_id - in: query - required: true - schema: - type: string - description: The task ID - title: Task Id - description: The task ID - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/PaginatedMessagesResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /spans: - post: - tags: - - Spans - summary: Create Span - description: Create a new span with the provided parameters - operationId: create_span_spans_post - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateSpanRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Span' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - get: - tags: - - Spans - summary: List Spans - description: List spans, optionally filtered by trace_id and/or task_id - operationId: list_spans_spans_get - parameters: - - name: trace_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Trace Id - - name: task_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Task Id - - name: limit - in: query - required: false - schema: - type: integer - maximum: 1000 - minimum: 1 - default: 50 - title: Limit - - name: page_number - in: query - required: false - schema: - type: integer - minimum: 1 - default: 1 - title: Page Number - - name: order_by - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Order By - - name: order_direction - in: query - required: false - schema: - type: string - default: desc - title: Order Direction - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/Span' - title: Response List Spans Spans Get - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /spans/{span_id}: - patch: - tags: - - Spans - summary: Partial Update Span - description: Update a span with the provided output data and mark it as complete - operationId: partial_update_span_spans__span_id__patch - parameters: - - name: span_id - in: path - required: true - schema: - type: string - title: Span Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateSpanRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Span' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - get: - tags: - - Spans - summary: Get Span - description: Get a span by ID - operationId: get_span_spans__span_id__get - parameters: - - name: span_id - in: path - required: true - schema: - type: string - title: Span Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Span' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /states: - post: - tags: - - States - summary: Create Task State - operationId: create_task_state_states_post - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateStateRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/State' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - get: - tags: - - States - summary: List States - description: List all states, optionally filtered by query parameters. - operationId: filter_states_states_get - parameters: - - name: task_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: Task ID - title: Task Id - description: Task ID - - name: agent_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: Agent ID - title: Agent Id - description: Agent ID - - name: limit - in: query - required: false - schema: - type: integer - minimum: 1 - description: Limit - default: 50 - title: Limit - description: Limit - - name: page_number - in: query - required: false - schema: - type: integer - minimum: 1 - description: Page number - default: 1 - title: Page Number - description: Page number - - name: order_by - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: Field to order by - title: Order By - description: Field to order by - - name: order_direction - in: query - required: false - schema: - type: string - description: Order direction (asc or desc) - default: desc - title: Order Direction - description: Order direction (asc or desc) - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/State' - title: Response Filter States States Get - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /states/{state_id}: - get: - tags: - - States - summary: Get State by State ID - description: Get a state by its unique state ID. - operationId: get_state_states__state_id__get - parameters: - - name: state_id - in: path - required: true - schema: - type: string - title: State Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/State' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - put: - tags: - - States - summary: Update Task State - operationId: update_task_state_states__state_id__put - parameters: - - name: state_id - in: path - required: true - schema: - type: string - title: State Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateStateRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/State' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - delete: - tags: - - States - summary: Delete Task State - operationId: delete_task_state_states__state_id__delete - parameters: - - name: state_id - in: path - required: true - schema: - type: string - title: State Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/State' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /events/{event_id}: - get: - tags: - - Events - summary: Get Event - operationId: get_event_events__event_id__get - parameters: - - name: event_id - in: path - required: true - schema: - type: string - title: Event Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Event' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /events: - get: - tags: - - Events - summary: List Events - description: 'List events for a specific task and agent. - - - Optionally filter for events after a specific sequence ID. - - Results are ordered by sequence_id.' - operationId: list_events_events_get - parameters: - - name: last_processed_event_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: Optional event ID to get events after this ID - title: Last Processed Event Id - description: Optional event ID to get events after this ID - - name: limit - in: query - required: false - schema: - anyOf: - - type: integer - maximum: 1000 - minimum: 1 - - type: 'null' - description: Optional limit on number of results - title: Limit - description: Optional limit on number of results - - name: task_id - in: query - required: true - schema: - type: string - description: The task ID to filter events by - title: Task Id - description: The task ID to filter events by - - name: agent_id - in: query - required: true - schema: - type: string - description: The agent ID to filter events by - title: Agent Id - description: The agent ID to filter events by - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/Event' - title: Response List Events Events Get - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tracker/{tracker_id}: - get: - tags: - - Agent Task Tracker - summary: Get Agent Task Tracker - description: Get agent task tracker by tracker ID - operationId: get_agent_task_tracker_tracker__tracker_id__get - parameters: - - name: tracker_id - in: path - required: true - schema: - type: string - title: Tracker Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/AgentTaskTracker' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - put: - tags: - - Agent Task Tracker - summary: Update Agent Task Tracker - description: Update agent task tracker by tracker ID - operationId: update_agent_task_tracker_tracker__tracker_id__put - parameters: - - name: tracker_id - in: path - required: true - schema: - type: string - title: Tracker Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateAgentTaskTrackerRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/AgentTaskTracker' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tracker: - get: - tags: - - Agent Task Tracker - summary: List Agent Task Trackers - description: List all agent task trackers, optionally filtered by query parameters. - operationId: filter_agent_task_tracker_tracker_get - parameters: - - name: agent_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: Agent ID - title: Agent Id - description: Agent ID - - name: task_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: Task ID - title: Task Id - description: Task ID - - name: limit - in: query - required: false - schema: - type: integer - minimum: 1 - description: Limit - default: 50 - title: Limit - description: Limit - - name: page_number - in: query - required: false - schema: - type: integer - minimum: 1 - description: Page number - default: 1 - title: Page Number - description: Page number - - name: order_by - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: Field to order by - title: Order By - description: Field to order by - - name: order_direction - in: query - required: false - schema: - type: string - description: Order direction (asc or desc) - default: desc - title: Order Direction - description: Order direction (asc or desc) - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/AgentTaskTracker' - title: Response Filter Agent Task Tracker Tracker Get - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agent_api_keys: - post: - tags: - - Agent APIKeys - summary: Create Api Key - operationId: create_api_key_agent_api_keys_post - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateAPIKeyRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/CreateAPIKeyResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - get: - tags: - - Agent APIKeys - summary: List API keys for an agent ID - description: List API keys for an agent ID. - operationId: list_agent_api_keys_agent_api_keys_get - parameters: - - name: agent_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Agent Id - - name: agent_name - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Agent Name - - name: limit - in: query - required: false - schema: - type: integer - maximum: 1000 - minimum: 1 - default: 50 - title: Limit - - name: page_number - in: query - required: false - schema: - type: integer - minimum: 1 - default: 1 - title: Page Number - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/AgentAPIKey' - title: Response List Agent Api Keys Agent Api Keys Get - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agent_api_keys/name/{name}: - get: - tags: - - Agent APIKeys - summary: Return named API key for the agent ID - description: Return named API key for the agent ID. - operationId: get_agent_api_key_by_name_agent_api_keys_name__name__get - parameters: - - name: name - in: path - required: true - schema: - type: string - title: Name - - name: agent_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Agent Id - - name: agent_name - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Agent Name - - name: api_key_type - in: query - required: false - schema: - $ref: '#/components/schemas/AgentAPIKeyType' - default: external - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/AgentAPIKey' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agent_api_keys/{id}: - get: - tags: - - Agent APIKeys - summary: Return the API key by ID - description: Return API key by ID. - operationId: get_agent_api_key_agent_api_keys__id__get - parameters: - - name: id - in: path - required: true - schema: - type: string - title: Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/AgentAPIKey' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - delete: - tags: - - Agent APIKeys - summary: Delete API key by ID - description: Delete API key by ID. - operationId: delete_agent_api_key_agent_api_keys__id__delete - parameters: - - name: id - in: path - required: true - schema: - type: string - title: Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: string - title: Response Delete Agent Api Key Agent Api Keys Id Delete - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agent_api_keys/name/{api_key_name}: - delete: - tags: - - Agent APIKeys - summary: Delete API key by name - description: Delete API key by name. - operationId: delete_agent_api_key_by_name_agent_api_keys_name__api_key_name__delete - parameters: - - name: api_key_name - in: path - required: true - schema: - type: string - title: Api Key Name - - name: agent_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Agent Id - - name: agent_name - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Agent Name - - name: api_key_type - in: query - required: false - schema: - $ref: '#/components/schemas/AgentAPIKeyType' - default: external - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: string - title: Response Delete Agent Api Key By Name Agent Api Keys Name Api - Key Name Delete - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /deployment-history/{deployment_id}: - get: - tags: - - Deployment History - summary: Get Deployment by ID - description: Get a deployment record by its unique ID. - operationId: get_deployment_by_id_deployment_history__deployment_id__get - parameters: - - name: deployment_id - in: path - required: true - schema: - type: string - title: Deployment Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/DeploymentHistory' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /deployment-history: - get: - tags: - - Deployment History - summary: List Deployments for an agent - description: List deployment history for an agent. - operationId: list_deployments_deployment_history_get - parameters: - - name: agent_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Agent Id - - name: agent_name - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Agent Name - - name: limit - in: query - required: false - schema: - type: integer - default: 50 - title: Limit - - name: page_number - in: query - required: false - schema: - type: integer - default: 1 - title: Page Number - - name: order_by - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Order By - - name: order_direction - in: query - required: false - schema: - type: string - default: desc - title: Order Direction - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/DeploymentHistory' - title: Response List Deployments Deployment History Get - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/{agent_id}/deployments: - post: - tags: - - Deployments - summary: Create Deployment - description: Create a new deployment record in PENDING status. - operationId: create_deployment_agents__agent_id__deployments_post - parameters: - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateDeploymentRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Deployment' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - get: - tags: - - Deployments - summary: List Deployments - description: List deployments for an agent, newest first. - operationId: list_deployments_agents__agent_id__deployments_get - parameters: - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - - name: limit - in: query - required: false - schema: - type: integer - minimum: 1 - description: Limit - default: 50 - title: Limit - description: Limit - - name: page_number - in: query - required: false - schema: - type: integer - minimum: 1 - description: Page number - default: 1 - title: Page Number - description: Page number - - name: order_by - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: Field to order by - title: Order By - description: Field to order by - - name: order_direction - in: query - required: false - schema: - type: string - description: Order direction (asc or desc) - default: desc - title: Order Direction - description: Order direction (asc or desc) - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/Deployment' - title: Response List Deployments Agents Agent Id Deployments Get - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/{agent_id}/deployments/{deployment_id}: - get: - tags: - - Deployments - summary: Get Deployment - description: Get a specific deployment by ID. - operationId: get_deployment_agents__agent_id__deployments__deployment_id__get - parameters: - - name: deployment_id - in: path - required: true - schema: - type: string - title: Deployment Id - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Deployment' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - delete: - tags: - - Deployments - summary: Delete Deployment - description: Delete a non-production deployment. - operationId: delete_deployment_agents__agent_id__deployments__deployment_id__delete - parameters: - - name: deployment_id - in: path - required: true - schema: - type: string - title: Deployment Id - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/{agent_id}/deployments/{deployment_id}/promote: - post: - tags: - - Deployments - summary: Promote Deployment - description: Promote a deployment to production with atomic cutover. - operationId: promote_deployment_agents__agent_id__deployments__deployment_id__promote_post - parameters: - - name: deployment_id - in: path - required: true - schema: - type: string - title: Deployment Id - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Deployment' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/{agent_id}/deployments/{deployment_id}/rpc: - post: - tags: - - Deployments - summary: Preview RPC - description: Send an RPC request to a specific deployment (for preview testing). - operationId: handle_deployment_rpc_agents__agent_id__deployments__deployment_id__rpc_post - parameters: - - name: deployment_id - in: path - required: true - schema: - type: string - title: Deployment Id - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/AgentRPCRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/AgentRPCResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/{agent_id}/schedules: - post: - tags: - - Schedules - summary: Create Schedule - description: Create a new schedule for recurring workflow execution for an agent. - operationId: create_schedule_agents__agent_id__schedules_post - parameters: - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateScheduleRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/ScheduleResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - get: - tags: - - Schedules - summary: List Agent Schedules - description: List all schedules for an agent. - operationId: list_schedules_agents__agent_id__schedules_get - parameters: - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - - name: page_size - in: query - required: false - schema: - type: integer - maximum: 1000 - minimum: 1 - default: 100 - title: Page Size - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/ScheduleListResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/{agent_id}/schedules/{schedule_name}: - get: - tags: - - Schedules - summary: Get Schedule - description: Get details of a schedule by its name. - operationId: get_schedule_agents__agent_id__schedules__schedule_name__get - parameters: - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - - name: schedule_name - in: path - required: true - schema: - type: string - title: Schedule Name - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/ScheduleResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - delete: - tags: - - Schedules - summary: Delete Schedule - description: Delete a schedule permanently. - operationId: delete_schedule_agents__agent_id__schedules__schedule_name__delete - parameters: - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - - name: schedule_name - in: path - required: true - schema: - type: string - title: Schedule Name - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/{agent_id}/schedules/{schedule_name}/pause: - post: - tags: - - Schedules - summary: Pause Schedule - description: Pause a schedule to stop it from executing. - operationId: pause_schedule_agents__agent_id__schedules__schedule_name__pause_post - parameters: - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - - name: schedule_name - in: path - required: true - schema: - type: string - title: Schedule Name - requestBody: - content: - application/json: - schema: - anyOf: - - $ref: '#/components/schemas/PauseScheduleRequest' - - type: 'null' - title: Request - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/ScheduleResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/{agent_id}/schedules/{schedule_name}/unpause: - post: - tags: - - Schedules - summary: Unpause Schedule - description: Unpause/resume a schedule to allow it to execute again. - operationId: unpause_schedule_agents__agent_id__schedules__schedule_name__unpause_post - parameters: - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - - name: schedule_name - in: path - required: true - schema: - type: string - title: Schedule Name - requestBody: - content: - application/json: - schema: - anyOf: - - $ref: '#/components/schemas/UnpauseScheduleRequest' - - type: 'null' - title: Request - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/ScheduleResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/{agent_id}/schedules/{schedule_name}/trigger: - post: - tags: - - Schedules - summary: Trigger Schedule - description: Trigger a schedule to run immediately, regardless of its regular - schedule. - operationId: trigger_schedule_agents__agent_id__schedules__schedule_name__trigger_post - parameters: - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - - name: schedule_name - in: path - required: true - schema: - type: string - title: Schedule Name - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/ScheduleResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /checkpoints/get-tuple: - post: - tags: - - Checkpoints - summary: Get Checkpoint Tuple - operationId: get_checkpoint_tuple_checkpoints_get_tuple_post - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/GetCheckpointTupleRequest' - required: true - responses: - '200': - description: Successful Response - content: - application/json: - schema: - anyOf: - - $ref: '#/components/schemas/CheckpointTupleResponse' - - type: 'null' - title: Response Get Checkpoint Tuple Checkpoints Get Tuple Post - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /checkpoints/put: - post: - tags: - - Checkpoints - summary: Put Checkpoint - operationId: put_checkpoint_checkpoints_put_post - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PutCheckpointRequest' - required: true - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/PutCheckpointResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /checkpoints/put-writes: - post: - tags: - - Checkpoints - summary: Put Writes - operationId: put_writes_checkpoints_put_writes_post - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PutWritesRequest' - required: true - responses: - '204': - description: Successful Response - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /checkpoints/list: - post: - tags: - - Checkpoints - summary: List Checkpoints - operationId: list_checkpoints_checkpoints_list_post - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/ListCheckpointsRequest' - required: true - responses: - '200': - description: Successful Response - content: - application/json: - schema: - items: - $ref: '#/components/schemas/CheckpointListItem' - type: array - title: Response List Checkpoints Checkpoints List Post - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /checkpoints/delete-thread: - post: - tags: - - Checkpoints - summary: Delete Thread - operationId: delete_thread_checkpoints_delete_thread_post - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteThreadRequest' - required: true - responses: - '204': - description: Successful Response - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/{task_id}/export: - get: - tags: - - task-retention - summary: Export Task - description: 'Build a self-contained snapshot of a task''s content surfaces. - - - Returns the exact payload format that POST /rehydrate accepts, so - - export โ†’ clean โ†’ rehydrate is a round-trip-equivalent operation.' - operationId: export_task_tasks__task_id__export_get - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/ExportTaskResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - post: - tags: - - task-retention - summary: Export Task To Url - description: 'Build the task snapshot and PUT it to a caller-supplied presigned - URL. - - - Use this when the snapshot is too large for a JSON response body (long - - conversations, deep reasoning content, many attachments). The upload URL - - must be https and resolve to a public address โ€” see SSRF guard.' - operationId: export_task_to_url_tasks__task_id__export_post - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ExportTaskToUrlRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/ExportTaskToUrlResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/{task_id}/clean: - post: - tags: - - task-retention - summary: Clean Task - description: 'Delete content-bearing rows for a stale task. - - - Refuses on active tasks, in-flight workflows, or unprocessed events - - regardless of `force`. The `force=true` flag only bypasses the - - idle-threshold check.' - operationId: clean_task_tasks__task_id__clean_post - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CleanTaskRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/CleanTaskResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/{task_id}/rehydrate: - post: - tags: - - task-retention - summary: Rehydrate Task - description: 'Restore content-bearing rows from a snapshot. - - - Two modes: - - - Inline: caller provides messages and task_states in the request body. - - - URL: caller provides snapshot_url; Agentex downloads and parses it. - - - Refuses if the task isn''t currently in a cleaned state, or if any supplied - - message/state ID already exists in Mongo (catches double-rehydrate).' - operationId: rehydrate_task_tasks__task_id__rehydrate_post - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/RehydrateTaskRequest' - responses: - '204': - description: Successful Response - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' -components: - schemas: - ACPType: - type: string - enum: - - sync - - async - - agentic - title: ACPType - Agent: - properties: - id: - type: string - title: Id - description: The unique identifier of the agent. - name: - type: string - title: Name - description: The unique name of the agent. - description: - type: string - title: Description - description: The description of the action. - status: - $ref: '#/components/schemas/AgentStatus' - description: The status of the action, indicating if it's building, ready, - failed, etc. - default: Unknown - acp_type: - $ref: '#/components/schemas/ACPType' - description: The type of the ACP Server (Either sync or async) - status_reason: - anyOf: - - type: string - - type: 'null' - title: Status Reason - description: The reason for the status of the action. - created_at: - type: string - format: date-time - title: Created At - description: The timestamp when the agent was created - updated_at: - type: string - format: date-time - title: Updated At - description: The timestamp when the agent was last updated - registration_metadata: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Registration Metadata - description: The metadata for the agent's registration. - registered_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Registered At - description: The timestamp when the agent was last registered - agent_input_type: - anyOf: - - $ref: '#/components/schemas/AgentInputType' - - type: 'null' - description: The type of input the agent expects. - production_deployment_id: - anyOf: - - type: string - - type: 'null' - title: Production Deployment Id - description: ID of the current production deployment. - type: object - required: - - id - - name - - description - - acp_type - - created_at - - updated_at - title: Agent - AgentAPIKey: - properties: - id: - type: string - title: Id - description: The unique identifier of the agent API key. - agent_id: - type: string - title: Agent Id - description: The UUID of the agent - created_at: - type: string - format: date-time - title: Created At - description: When the agent API key was created - name: - anyOf: - - type: string - - type: 'null' - title: Name - description: The optional name of the agent API key. - api_key_type: - $ref: '#/components/schemas/AgentAPIKeyType' - description: The type of the agent API key (either internal or external) - type: object - required: - - id - - agent_id - - created_at - - name - - api_key_type - title: AgentAPIKey - AgentAPIKeyType: - type: string - enum: - - internal - - external - - github - - slack - title: AgentAPIKeyType - AgentInputType: - type: string - enum: - - text - - json - title: AgentInputType - AgentRPCMethod: - type: string - enum: - - event/send - - task/create - - message/send - - task/cancel - title: AgentRPCMethod - AgentRPCParams: - anyOf: - - $ref: '#/components/schemas/CreateTaskRequest' - - $ref: '#/components/schemas/CancelTaskRequest' - - $ref: '#/components/schemas/SendMessageRequest' - - $ref: '#/components/schemas/SendEventRequest' - title: AgentRPCParams - description: The parameters for the agent RPC request - AgentRPCRequest: - properties: - jsonrpc: - type: string - const: '2.0' - title: Jsonrpc - default: '2.0' - method: - $ref: '#/components/schemas/AgentRPCMethod' - params: - $ref: '#/components/schemas/AgentRPCParams' - id: - anyOf: - - type: integer - - type: string - - type: 'null' - title: Id - type: object - required: - - method - - params - title: AgentRPCRequest - AgentRPCResponse: - properties: - jsonrpc: - type: string - const: '2.0' - title: Jsonrpc - default: '2.0' - result: - $ref: '#/components/schemas/AgentRPCResult' - description: The result of the agent RPC request - error: - anyOf: - - {} - - type: 'null' - title: Error - id: - anyOf: - - type: integer - - type: string - - type: 'null' - title: Id - type: object - required: - - result - title: AgentRPCResponse - AgentRPCResult: - anyOf: - - items: - $ref: '#/components/schemas/TaskMessage' - type: array - - $ref: '#/components/schemas/TaskMessageUpdate' - - $ref: '#/components/schemas/Task' - - $ref: '#/components/schemas/Event' - - type: 'null' - title: AgentRPCResult - AgentStatus: - type: string - enum: - - Ready - - Failed - - Unknown - - Deleted - - Unhealthy - - BuildOnly - title: AgentStatus - AgentTaskTracker: - properties: - id: - type: string - title: Id - description: The UUID of the agent task tracker - agent_id: - type: string - title: Agent Id - description: The UUID of the agent - task_id: - type: string - title: Task Id - description: The UUID of the task - status: - anyOf: - - type: string - - type: 'null' - title: Status - description: Processing status - status_reason: - anyOf: - - type: string - - type: 'null' - title: Status Reason - description: Optional status reason - last_processed_event_id: - anyOf: - - type: string - - type: 'null' - title: Last Processed Event Id - description: The last processed event ID - created_at: - type: string - format: date-time - title: Created At - description: When the agent task tracker was created - updated_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Updated At - description: When the agent task tracker was last updated - type: object - required: - - id - - agent_id - - task_id - - created_at - title: AgentTaskTracker - BatchCreateTaskMessagesRequest: - properties: - task_id: - type: string - title: The unique id of the task to send the messages to - contents: - items: - $ref: '#/components/schemas/TaskMessageContent' - type: array - title: The messages to send to the task. The order of the messages will - be the order they are added to the task. - created_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Optional caller-supplied base creation timestamp for the batch - description: Optional base timestamp. Each message in the batch is stamped - with base + i milliseconds to guarantee unique, monotonic ordering. If - omitted, the server stamps datetime.now(UTC) at insert time. - type: object - required: - - task_id - - contents - title: BatchCreateTaskMessagesRequest - BatchUpdateTaskMessagesRequest: - properties: - task_id: - type: string - title: The unique id of the task to update the messages of - updates: - additionalProperties: - $ref: '#/components/schemas/TaskMessageContent' - type: object - title: The updates to apply to the messages. The key is the TaskMessage - id and the value is the TaskMessageContent to update the message with. - type: object - required: - - task_id - - updates - title: BatchUpdateTaskMessagesRequest - BlobData: - properties: - channel: - type: string - title: Channel name - version: - type: string - title: Channel version - type: - type: string - title: Serialization type tag - blob: - anyOf: - - type: string - - type: 'null' - title: Base64-encoded binary data - type: object - required: - - channel - - version - - type - title: BlobData - BlobResponse: - properties: - channel: - type: string - title: Channel - version: - type: string - title: Version - type: - type: string - title: Type - blob: - anyOf: - - type: string - - type: 'null' - title: Blob - type: object - required: - - channel - - version - - type - title: BlobResponse - CancelTaskRequest: - properties: - task_id: - anyOf: - - type: string - - type: 'null' - title: Task Id - description: The ID of the task to cancel. Either this or task_name must - be provided. - task_name: - anyOf: - - type: string - - type: 'null' - title: Task Name - description: The name of the task to cancel. Either this or task_id must - be provided. - type: object - title: CancelTaskRequest - CheckpointListItem: - properties: - thread_id: - type: string - title: Thread Id - checkpoint_ns: - type: string - title: Checkpoint Ns - checkpoint_id: - type: string - title: Checkpoint Id - parent_checkpoint_id: - anyOf: - - type: string - - type: 'null' - title: Parent Checkpoint Id - checkpoint: - additionalProperties: true - type: object - title: Checkpoint - metadata: - additionalProperties: true - type: object - title: Metadata - type: object - required: - - thread_id - - checkpoint_ns - - checkpoint_id - - checkpoint - - metadata - title: CheckpointListItem - CheckpointTupleResponse: - properties: - thread_id: - type: string - title: Thread Id - checkpoint_ns: - type: string - title: Checkpoint Ns - checkpoint_id: - type: string - title: Checkpoint Id - parent_checkpoint_id: - anyOf: - - type: string - - type: 'null' - title: Parent Checkpoint Id - checkpoint: - additionalProperties: true - type: object - title: Checkpoint - metadata: - additionalProperties: true - type: object - title: Metadata - blobs: - items: - $ref: '#/components/schemas/BlobResponse' - type: array - title: Blobs - pending_writes: - items: - $ref: '#/components/schemas/WriteResponse' - type: array - title: Pending Writes - type: object - required: - - thread_id - - checkpoint_ns - - checkpoint_id - - checkpoint - - metadata - title: CheckpointTupleResponse - CleanTaskRequest: - properties: - force: - type: boolean - title: Force - description: Skip the idle-threshold check. Active-workflow and unprocessed-events - checks still apply. Admin use only. - default: false - idle_days: - type: integer - minimum: 1.0 - title: Idle Days - description: Idle threshold in days (ignored when force=true). - default: 7 - type: object - title: CleanTaskRequest - CleanTaskResponse: - properties: - task_id: - type: string - title: Task Id - cleaned_at: - type: string - format: date-time - title: Cleaned At - messages_deleted: - type: integer - title: Messages Deleted - task_states_deleted: - type: integer - title: Task States Deleted - events_deleted: - type: integer - title: Events Deleted - type: object - required: - - task_id - - cleaned_at - - messages_deleted - - task_states_deleted - - events_deleted - title: CleanTaskResponse - CreateAPIKeyRequest: - properties: - agent_id: - anyOf: - - type: string - - type: 'null' - title: Agent Id - description: The UUID of the agent - agent_name: - anyOf: - - type: string - - type: 'null' - title: Agent Name - description: The name of the agent - if not provided, the agent_id must - be set. - name: - type: string - title: Name - description: The name of the agent's API key. - api_key_type: - $ref: '#/components/schemas/AgentAPIKeyType' - description: The type of the agent API key (external by default). - default: external - api_key: - anyOf: - - type: string - - type: 'null' - title: Api Key - description: Optionally provide the API key value - if not set, one will - be generated. - type: object - required: - - name - title: CreateAPIKeyRequest - CreateAPIKeyResponse: - properties: - id: - type: string - title: Id - description: The unique identifier of the agent API key. - agent_id: - type: string - title: Agent Id - description: The UUID of the agent - created_at: - type: string - format: date-time - title: Created At - description: When the agent API key was created - name: - anyOf: - - type: string - - type: 'null' - title: Name - description: The optional name of the agent API key. - api_key_type: - $ref: '#/components/schemas/AgentAPIKeyType' - description: The type of the created agent API key (external). - api_key: - type: string - title: Api Key - description: The value of the newly created API key. - type: object - required: - - id - - agent_id - - created_at - - name - - api_key_type - - api_key - title: CreateAPIKeyResponse - CreateDeploymentRequest: - properties: - docker_image: - type: string - title: Docker Image - description: Full Docker image URI. - registration_metadata: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Registration Metadata - description: Git/build metadata (commit_hash, branch_name, author_name, - author_email, build_timestamp). - sgp_deploy_id: - anyOf: - - type: string - - type: 'null' - title: Sgp Deploy Id - description: SGP deployment ID. - helm_release_name: - anyOf: - - type: string - - type: 'null' - title: Helm Release Name - description: Helm release name. - type: object - required: - - docker_image - title: CreateDeploymentRequest - CreateScheduleRequest: - properties: - name: - type: string - maxLength: 64 - minLength: 1 - pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$ - title: Schedule Name - description: Human-readable name for the schedule (e.g., 'weekly-profiling'). - Will be combined with agent_id to form the full schedule_id. - workflow_name: - type: string - title: Workflow Name - description: Name of the Temporal workflow to execute (e.g., 'sae-orchestrator') - task_queue: - type: string - title: Task Queue - description: Temporal task queue where the agent's worker is listening - workflow_params: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Workflow Parameters - description: Parameters to pass to the workflow - cron_expression: - anyOf: - - type: string - - type: 'null' - title: Cron Expression - description: Cron expression for scheduling (e.g., '0 0 * * 0' for weekly - on Sunday) - interval_seconds: - anyOf: - - type: integer - minimum: 1.0 - - type: 'null' - title: Interval Seconds - description: Alternative to cron - run every N seconds - execution_timeout_seconds: - anyOf: - - type: integer - minimum: 1.0 - - type: 'null' - title: Execution Timeout - description: Maximum time in seconds for each workflow execution - start_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Start At - description: When the schedule should start being active - end_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: End At - description: When the schedule should stop being active - paused: - type: boolean - title: Paused - description: Whether to create the schedule in a paused state - default: false - type: object - required: - - name - - workflow_name - - task_queue - title: CreateScheduleRequest - description: Request model for creating a new schedule for an agent - CreateSpanRequest: - properties: - id: - anyOf: - - type: string - - type: 'null' - title: Unique Span ID - description: Unique identifier for the span. If not provided, an ID will - be generated. - trace_id: - type: string - title: The trace ID for this span - description: Unique identifier for the trace this span belongs to - task_id: - anyOf: - - type: string - - type: 'null' - title: The task ID this span is associated with - description: ID of the task this span belongs to - parent_id: - anyOf: - - type: string - - type: 'null' - title: The parent span ID if this is a child span - description: ID of the parent span if this is a child span in a trace - name: - type: string - title: The name of the span - description: Name that describes what operation this span represents - start_time: - type: string - format: date-time - title: The start time of the span - description: The time the span started - end_time: - anyOf: - - type: string - format: date-time - - type: 'null' - title: The end time of the span - description: The time the span ended - input: - anyOf: - - additionalProperties: true - type: object - - items: - additionalProperties: true - type: object - type: array - - type: 'null' - title: The input data for the span - description: Input parameters or data for the operation - output: - anyOf: - - additionalProperties: true - type: object - - items: - additionalProperties: true - type: object - type: array - - type: 'null' - title: The output data from the span - description: Output data resulting from the operation - data: - anyOf: - - additionalProperties: true - type: object - - items: - additionalProperties: true - type: object - type: array - - type: 'null' - title: Additional data associated with the span - description: Any additional metadata or context for the span - type: object - required: - - trace_id - - name - - start_time - title: CreateSpanRequest - CreateStateRequest: - properties: - task_id: - type: string - title: The unique id of the task to send the state to - agent_id: - type: string - title: The unique id of the agent to send the state to - state: - additionalProperties: true - type: object - title: The state to send to the task. - type: object - required: - - task_id - - agent_id - - state - title: CreateStateRequest - CreateTaskMessageRequest: - properties: - task_id: - type: string - title: The unique id of the task to send the message to - content: - $ref: '#/components/schemas/TaskMessageContent' - title: The message to send to the task. - streaming_status: - anyOf: - - type: string - enum: - - IN_PROGRESS - - DONE - - type: 'null' - title: The streaming status of the message - created_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Optional caller-supplied creation timestamp - description: Optional timestamp for the message. Workflow callers should - pass workflow.now() (Temporal's deterministic monotonic clock) so that - two awaited messages.create calls from the same workflow are guaranteed - to have monotonic timestamps regardless of HTTP scheduling at the server. - If omitted, the server's wall clock at insert time is used. - type: object - required: - - task_id - - content - title: CreateTaskMessageRequest - CreateTaskRequest: - properties: - name: - anyOf: - - type: string - - type: 'null' - title: Name - description: 'Optional human-readable name for the task. When set it must - be globally unique. task/create is get-or-create by name: reusing an existing - name returns the existing task (with its prior history) instead of creating - a new one, so omit name (or make it unique, e.g. by appending a UUID) - whenever each call should produce a fresh task.' - params: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Params - description: The parameters for the task. On a get-or-create by name, providing - params overwrites the existing task's params (it is not a pure read). - task_metadata: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Task Metadata - description: Caller-provided metadata to persist on the task row. Only applied - at task creation; ignored if a task with this name already exists. Forwarded - to the agent inside the ACP payload for backward compatibility. - type: object - title: CreateTaskRequest - DataContent: - properties: - type: - type: string - const: data - title: Type - description: The type of the message, in this case `data`. - default: data - author: - $ref: '#/components/schemas/MessageAuthor' - description: The role of the messages author, in this case `system`, `user`, - `assistant`, or `tool`. - style: - $ref: '#/components/schemas/MessageStyle' - description: The style of the message. This is used by the client to determine - how to display the message. - default: static - data: - additionalProperties: true - type: object - title: Data - description: The contents of the data message. - type: object - required: - - author - - data - title: DataContent - DataContentEntity: - properties: - type: - type: string - const: data - title: Type - description: The type of the message, in this case `data`. - default: data - author: - $ref: '#/components/schemas/MessageAuthor' - description: The role of the messages author, in this case `system`, `user`, - `assistant`, or `tool`. - style: - $ref: '#/components/schemas/MessageStyle' - description: The style of the message. This is used by the client to determine - how to display the message. - default: static - data: - additionalProperties: true - type: object - title: Data - description: The contents of the data message. - type: object - required: - - author - - data - title: DataContentEntity - DataDelta: - properties: - type: - type: string - const: data - title: Type - default: data - data_delta: - anyOf: - - type: string - - type: 'null' - title: Data Delta - default: '' - type: object - title: DataDelta - description: Delta for data updates - DeleteResponse: - properties: - id: - type: string - title: Id - message: - type: string - title: Message - type: object - required: - - id - - message - title: DeleteResponse - DeleteThreadRequest: - properties: - thread_id: - type: string - title: Thread ID - type: object - required: - - thread_id - title: DeleteThreadRequest - Deployment: - properties: - id: - type: string - title: Id - description: The unique identifier of the deployment. - agent_id: - type: string - title: Agent Id - description: The agent this deployment belongs to. - docker_image: - type: string - title: Docker Image - description: Full Docker image URI. - registration_metadata: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Registration Metadata - description: Git/build metadata from the agent pod. - status: - $ref: '#/components/schemas/DeploymentStatus' - description: Current deployment status. - acp_url: - anyOf: - - type: string - - type: 'null' - title: Acp Url - description: ACP URL set when agent registers. - is_production: - type: boolean - title: Is Production - description: Whether this is the production deployment. - sgp_deploy_id: - anyOf: - - type: string - - type: 'null' - title: Sgp Deploy Id - description: Correlates to SGP's agentex_deploys.id. - helm_release_name: - anyOf: - - type: string - - type: 'null' - title: Helm Release Name - description: Helm release name for cleanup. - created_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Created At - description: When the deployment was created. - promoted_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Promoted At - description: When promoted to production. - expires_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Expires At - description: When marked for cleanup. - type: object - required: - - id - - agent_id - - docker_image - - status - - is_production - title: Deployment - DeploymentHistory: - properties: - id: - type: string - title: Id - description: The unique identifier of the deployment record - agent_id: - type: string - title: Agent Id - description: The ID of the agent this deployment belongs to - author_name: - type: string - title: Author Name - description: Name of the commit author - author_email: - type: string - title: Author Email - description: Email of the commit author - branch_name: - type: string - title: Branch Name - description: Name of the branch - build_timestamp: - type: string - format: date-time - title: Build Timestamp - description: When the build was created - deployment_timestamp: - type: string - format: date-time - title: Deployment Timestamp - description: When this deployment was first seen in the system - commit_hash: - type: string - title: Commit Hash - description: Git commit hash for this deployment - type: object - required: - - id - - agent_id - - author_name - - author_email - - branch_name - - build_timestamp - - deployment_timestamp - - commit_hash - title: DeploymentHistory - description: API schema for deployment history. - DeploymentStatus: - type: string - enum: - - Pending - - Ready - - Failed - title: DeploymentStatus - Event: - properties: - id: - type: string - title: Id - description: The UUID of the event - sequence_id: - type: integer - title: Sequence Id - description: The sequence ID of the event - task_id: - type: string - title: Task Id - description: The UUID of the task that the event belongs to - agent_id: - type: string - title: Agent Id - description: The UUID of the agent that the event belongs to - created_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Created At - description: The timestamp of the event - content: - anyOf: - - $ref: '#/components/schemas/TaskMessageContent' - - type: 'null' - description: The content of the event - type: object - required: - - id - - sequence_id - - task_id - - agent_id - title: Event - ExportTaskResponse: - properties: - task_id: - type: string - title: Task Id - messages: - items: - $ref: '#/components/schemas/TaskMessageEntity' - type: array - title: Messages - task_states: - items: - $ref: '#/components/schemas/StateEntity' - type: array - title: Task States - type: object - required: - - task_id - title: ExportTaskResponse - description: Wire format mirrors the entity directly โ€” schema parity is intentional. - ExportTaskToUrlRequest: - properties: - upload_url: - type: string - maxLength: 2083 - minLength: 1 - format: uri - title: Upload Url - description: Presigned PUT URL where Agentex will upload the task snapshot - as JSON. Must be https; must resolve to a public address. - type: object - required: - - upload_url - title: ExportTaskToUrlRequest - ExportTaskToUrlResponse: - properties: - task_id: - type: string - title: Task Id - upload_url: - type: string - title: Upload Url - uploaded_bytes: - type: integer - title: Uploaded Bytes - messages_count: - type: integer - title: Messages Count - task_states_count: - type: integer - title: Task States Count - type: object - required: - - task_id - - upload_url - - uploaded_bytes - - messages_count - - task_states_count - title: ExportTaskToUrlResponse - FileAttachment: - properties: - file_id: - type: string - title: File Id - description: The unique ID of the attached file - name: - type: string - title: Name - description: The name of the file - size: - type: integer - title: Size - description: The size of the file in bytes - type: - type: string - title: Type - description: The MIME type or content type of the file - type: object - required: - - file_id - - name - - size - - type - title: FileAttachment - description: Represents a file attachment in messages. - FileAttachmentEntity: - properties: - file_id: - type: string - title: File Id - description: The unique ID of the attached file - name: - type: string - title: Name - description: The name of the file - size: - type: integer - title: Size - description: The size of the file in bytes - type: - type: string - title: Type - description: The MIME type or content type of the file - type: object - required: - - file_id - - name - - size - - type - title: FileAttachmentEntity - description: Represents a file attachment in messages. - GetCheckpointTupleRequest: - properties: - thread_id: - type: string - title: Thread ID - checkpoint_ns: - type: string - title: Checkpoint namespace - default: '' - checkpoint_id: - anyOf: - - type: string - - type: 'null' - title: Checkpoint ID (None = latest) - type: object - required: - - thread_id - title: GetCheckpointTupleRequest - HTTPValidationError: - properties: - detail: - items: - $ref: '#/components/schemas/ValidationError' - type: array - title: Detail - type: object - title: HTTPValidationError - ListCheckpointsRequest: - properties: - thread_id: - type: string - title: Thread ID - checkpoint_ns: - anyOf: - - type: string - - type: 'null' - title: Checkpoint namespace - before_checkpoint_id: - anyOf: - - type: string - - type: 'null' - title: Before checkpoint ID - filter_metadata: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Metadata filter (JSONB @>) - limit: - type: integer - maximum: 1000.0 - minimum: 1.0 - title: Max results - default: 100 - type: object - required: - - thread_id - title: ListCheckpointsRequest - MessageAuthor: - type: string - enum: - - user - - agent - title: MessageAuthor - MessageStyle: - type: string - enum: - - static - - active - title: MessageStyle - PaginatedMessagesResponse: - properties: - data: - items: - $ref: '#/components/schemas/TaskMessage' - type: array - title: Data - description: List of messages - next_cursor: - anyOf: - - type: string - - type: 'null' - title: Next Cursor - description: Cursor for fetching the next page of older messages - has_more: - type: boolean - title: Has More - description: Whether there are more messages to fetch - default: false - type: object - required: - - data - title: PaginatedMessagesResponse - description: Response with cursor pagination metadata. - PauseScheduleRequest: - properties: - note: - anyOf: - - type: string - - type: 'null' - title: Note - description: Optional note explaining why the schedule was paused - type: object - title: PauseScheduleRequest - description: Request model for pausing a schedule - PutCheckpointRequest: - properties: - thread_id: - type: string - title: Thread ID - checkpoint_ns: - type: string - title: Checkpoint namespace - default: '' - checkpoint_id: - type: string - title: Checkpoint ID - parent_checkpoint_id: - anyOf: - - type: string - - type: 'null' - title: Parent checkpoint ID - checkpoint: - additionalProperties: true - type: object - title: Checkpoint JSONB payload - metadata: - additionalProperties: true - type: object - title: Checkpoint metadata - blobs: - items: - $ref: '#/components/schemas/BlobData' - type: array - title: Channel blob data - type: object - required: - - thread_id - - checkpoint_id - - checkpoint - title: PutCheckpointRequest - PutCheckpointResponse: - properties: - thread_id: - type: string - title: Thread Id - checkpoint_ns: - type: string - title: Checkpoint Ns - checkpoint_id: - type: string - title: Checkpoint Id - type: object - required: - - thread_id - - checkpoint_ns - - checkpoint_id - title: PutCheckpointResponse - PutWritesRequest: - properties: - thread_id: - type: string - title: Thread ID - checkpoint_ns: - type: string - title: Checkpoint namespace - default: '' - checkpoint_id: - type: string - title: Checkpoint ID - writes: - items: - $ref: '#/components/schemas/WriteData' - type: array - title: Write data - upsert: - type: boolean - title: Upsert mode - default: false - type: object - required: - - thread_id - - checkpoint_id - - writes - title: PutWritesRequest - ReasoningContent: - properties: - type: - type: string - const: reasoning - title: Type - description: The type of the message, in this case `reasoning`. - default: reasoning - author: - $ref: '#/components/schemas/MessageAuthor' - description: The role of the messages author, in this case `system`, `user`, - `assistant`, or `tool`. - style: - $ref: '#/components/schemas/MessageStyle' - description: The style of the message. This is used by the client to determine - how to display the message. - default: static - summary: - items: - type: string - type: array - title: Summary - description: A list of short reasoning summaries - content: - anyOf: - - items: - type: string - type: array - - type: 'null' - title: Content - description: The reasoning content or chain-of-thought text - type: object - required: - - author - - summary - title: ReasoningContent - ReasoningContentDelta: - properties: - type: - type: string - const: reasoning_content - title: Type - default: reasoning_content - content_index: - type: integer - title: Content Index - content_delta: - anyOf: - - type: string - - type: 'null' - title: Content Delta - default: '' - type: object - required: - - content_index - title: ReasoningContentDelta - description: Delta for reasoning content updates - ReasoningContentEntity: - properties: - type: - type: string - const: reasoning - title: Type - description: The type of the message, in this case `reasoning`. - default: reasoning - author: - $ref: '#/components/schemas/MessageAuthor' - description: The role of the messages author, in this case `system`, `user`, - `assistant`, or `tool`. - style: - $ref: '#/components/schemas/MessageStyle' - description: The style of the message. This is used by the client to determine - how to display the message. - default: static - summary: - items: - type: string - type: array - title: Summary - description: A list of short reasoning summaries - content: - anyOf: - - items: - type: string - type: array - - type: 'null' - title: Content - description: The reasoning content or chain-of-thought text - type: object - required: - - author - - summary - title: ReasoningContentEntity - ReasoningSummaryDelta: - properties: - type: - type: string - const: reasoning_summary - title: Type - default: reasoning_summary - summary_index: - type: integer - title: Summary Index - summary_delta: - anyOf: - - type: string - - type: 'null' - title: Summary Delta - default: '' - type: object - required: - - summary_index - title: ReasoningSummaryDelta - description: Delta for reasoning summary updates - RegisterAgentRequest: - properties: - name: - type: string - pattern: ^[a-z0-9-]+$ - title: Name - description: The unique name of the agent. - description: - type: string - title: Description - description: The description of the agent. - acp_url: - type: string - title: Acp Url - description: The URL of the ACP server for the agent. - agent_id: - anyOf: - - type: string - - type: 'null' - title: Agent Id - description: Optional agent ID if the agent already exists and needs to - be updated. - acp_type: - $ref: '#/components/schemas/ACPType' - description: The type of ACP to use for the agent. - registration_metadata: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Registration Metadata - description: The metadata for the agent's registration. - agent_input_type: - anyOf: - - $ref: '#/components/schemas/AgentInputType' - - type: 'null' - description: The type of input the agent expects. - type: object - required: - - name - - description - - acp_url - - acp_type - title: RegisterAgentRequest - RegisterAgentResponse: - properties: - id: - type: string - title: Id - description: The unique identifier of the agent. - name: - type: string - title: Name - description: The unique name of the agent. - description: - type: string - title: Description - description: The description of the action. - status: - $ref: '#/components/schemas/AgentStatus' - description: The status of the action, indicating if it's building, ready, - failed, etc. - default: Unknown - acp_type: - $ref: '#/components/schemas/ACPType' - description: The type of the ACP Server (Either sync or async) - status_reason: - anyOf: - - type: string - - type: 'null' - title: Status Reason - description: The reason for the status of the action. - created_at: - type: string - format: date-time - title: Created At - description: The timestamp when the agent was created - updated_at: - type: string - format: date-time - title: Updated At - description: The timestamp when the agent was last updated - registration_metadata: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Registration Metadata - description: The metadata for the agent's registration. - registered_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Registered At - description: The timestamp when the agent was last registered - agent_input_type: - anyOf: - - $ref: '#/components/schemas/AgentInputType' - - type: 'null' - description: The type of input the agent expects. - production_deployment_id: - anyOf: - - type: string - - type: 'null' - title: Production Deployment Id - description: ID of the current production deployment. - agent_api_key: - anyOf: - - type: string - - type: 'null' - title: Agent Api Key - description: The API key for the agent, if applicable. - type: object - required: - - id - - name - - description - - acp_type - - created_at - - updated_at - title: RegisterAgentResponse - description: Response model for registering an agent. - RegisterBuildRequest: - properties: - name: - type: string - pattern: ^[a-z0-9-]+$ - title: Name - description: The unique name of the agent. - description: - type: string - title: Description - description: The description of the agent. - registration_metadata: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Registration Metadata - description: The metadata for the agent's build registration. - agent_input_type: - anyOf: - - $ref: '#/components/schemas/AgentInputType' - - type: 'null' - description: The type of input the agent expects. - type: object - required: - - name - - description - title: RegisterBuildRequest - description: 'Request model for registering an agent at build time (pre-deploy). - - - Unlike RegisterAgentRequest, there is no acp_url (the agent is not running - - yet) and no acp_type is required. The created agent is left in BUILD_ONLY - - status so it can be permissioned/shared before it is deployed.' - RehydrateTaskRequest: - properties: - task_id: - type: string - title: Task Id - messages: - items: - $ref: '#/components/schemas/TaskMessageEntity' - type: array - title: Messages - task_states: - items: - $ref: '#/components/schemas/StateEntity' - type: array - title: Task States - snapshot_url: - anyOf: - - type: string - maxLength: 2083 - minLength: 1 - format: uri - - type: 'null' - title: Snapshot Url - description: Presigned GET URL whose body is a JSON-encoded TaskSnapshotEntity. - Must be https; must resolve to a public address. When set, messages/task_states - must be empty. - type: object - required: - - task_id - title: RehydrateTaskRequest - description: 'Either provide inline content (messages + task_states) or a snapshot_url - - pointing at a presigned JSON download. Mixing both is rejected. - - - The inline form is the canonical shape used by export''s GET response, so - - snapshot โ†’ clean โ†’ rehydrate round-trips cleanly without serialization - - changes.' - ScheduleActionInfo: - properties: - workflow_name: - type: string - title: Workflow Name - description: Name of the workflow being executed - workflow_id_prefix: - type: string - title: Workflow ID Prefix - description: Prefix for workflow execution IDs - task_queue: - type: string - title: Task Queue - description: Task queue for the workflow - workflow_params: - anyOf: - - items: {} - type: array - - type: 'null' - title: Workflow Parameters - description: Parameters passed to the workflow - type: object - required: - - workflow_name - - workflow_id_prefix - - task_queue - title: ScheduleActionInfo - description: Information about the scheduled action - ScheduleListItem: - properties: - schedule_id: - type: string - title: Schedule ID - description: Unique identifier for the schedule - name: - type: string - title: Schedule Name - description: Human-readable name for the schedule - agent_id: - type: string - title: Agent ID - description: ID of the agent this schedule belongs to - state: - $ref: '#/components/schemas/ScheduleState' - title: State - description: Current state of the schedule - workflow_name: - anyOf: - - type: string - - type: 'null' - title: Workflow Name - description: Name of the scheduled workflow - next_action_time: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Next Action Time - description: Next scheduled execution time - type: object - required: - - schedule_id - - name - - agent_id - - state - title: ScheduleListItem - description: Abbreviated schedule info for list responses - ScheduleListResponse: - properties: - schedules: - items: - $ref: '#/components/schemas/ScheduleListItem' - type: array - title: Schedules - description: List of schedules - total: - type: integer - title: Total - description: Total number of schedules - type: object - required: - - schedules - - total - title: ScheduleListResponse - description: Response model for listing schedules - ScheduleResponse: - properties: - schedule_id: - type: string - title: Schedule ID - description: Unique identifier for the schedule - name: - type: string - title: Schedule Name - description: Human-readable name for the schedule - agent_id: - type: string - title: Agent ID - description: ID of the agent this schedule belongs to - state: - $ref: '#/components/schemas/ScheduleState' - title: State - description: Current state of the schedule - action: - $ref: '#/components/schemas/ScheduleActionInfo' - title: Action - spec: - $ref: '#/components/schemas/ScheduleSpecInfo' - title: Spec - description: Schedule specification - num_actions_taken: - type: integer - title: Number of Actions Taken - description: Number of times the schedule has executed - default: 0 - num_actions_missed: - type: integer - title: Number of Actions Missed - description: Number of scheduled executions that were missed - default: 0 - next_action_times: - items: - type: string - format: date-time - type: array - title: Next Action Times - description: Upcoming scheduled execution times - last_action_time: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Last Action Time - description: When the schedule last executed - created_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Created At - description: When the schedule was created - type: object - required: - - schedule_id - - name - - agent_id - - state - - action - - spec - title: ScheduleResponse - description: Response model for schedule operations - ScheduleSpecInfo: - properties: - cron_expressions: - items: - type: string - type: array - title: Cron Expressions - description: Cron expressions for the schedule - intervals_seconds: - items: - type: integer - type: array - title: Interval Seconds - description: Interval specifications in seconds - start_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Start At - description: When the schedule starts being active - end_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: End At - description: When the schedule stops being active - type: object - title: ScheduleSpecInfo - description: Information about the schedule specification - ScheduleState: - type: string - enum: - - ACTIVE - - PAUSED - title: ScheduleState - description: Schedule state enum - SendEventRequest: - properties: - task_id: - anyOf: - - type: string - - type: 'null' - title: Task Id - description: The ID of the task that the event was sent to - task_name: - anyOf: - - type: string - - type: 'null' - title: Task Name - description: The name of the task that the event was sent to - content: - anyOf: - - $ref: '#/components/schemas/TaskMessageContent' - - type: 'null' - description: The content to send to the event - type: object - title: SendEventRequest - SendMessageRequest: - properties: - task_id: - anyOf: - - type: string - - type: 'null' - title: Task Id - description: The ID of the task that the message was sent to - task_name: - anyOf: - - type: string - - type: 'null' - title: Task Name - description: The name of the task that the message was sent to - content: - $ref: '#/components/schemas/TaskMessageContent' - description: The message that was sent to the agent - stream: - type: boolean - title: Stream - description: Whether to stream the response message back to the client - default: false - task_params: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Task Params - description: The parameters for the task (only used when creating new tasks) - type: object - required: - - content - title: SendMessageRequest - Span: - properties: - id: - type: string - title: Unique Span ID - trace_id: - type: string - title: The trace ID for this span - description: Unique identifier for the trace this span belongs to - task_id: - anyOf: - - type: string - - type: 'null' - title: The task ID this span is associated with - description: ID of the task this span belongs to - parent_id: - anyOf: - - type: string - - type: 'null' - title: The parent span ID if this is a child span - description: ID of the parent span if this is a child span in a trace - name: - type: string - title: The name of the span - description: Name that describes what operation this span represents - start_time: - type: string - format: date-time - title: The start time of the span - description: The time the span started - end_time: - anyOf: - - type: string - format: date-time - - type: 'null' - title: The end time of the span - description: The time the span ended - input: - anyOf: - - additionalProperties: true - type: object - - items: - additionalProperties: true - type: object - type: array - - type: 'null' - title: The input data for the span - description: Input parameters or data for the operation - output: - anyOf: - - additionalProperties: true - type: object - - items: - additionalProperties: true - type: object - type: array - - type: 'null' - title: The output data from the span - description: Output data resulting from the operation - data: - anyOf: - - additionalProperties: true - type: object - - items: - additionalProperties: true - type: object - type: array - - type: 'null' - title: Additional data associated with the span - description: Any additional metadata or context for the span - type: object - required: - - id - - trace_id - - name - - start_time - title: Span - State: - properties: - task_id: - type: string - title: The unique id of the task to send the state to - agent_id: - type: string - title: The unique id of the agent to send the state to - state: - additionalProperties: true - type: object - title: The state to send to the task. - id: - type: string - title: Id - description: The task state's unique id - created_at: - type: string - format: date-time - title: Created At - description: The timestamp when the state was created - updated_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Updated At - description: The timestamp when the state was last updated - type: object - required: - - task_id - - agent_id - - state - - id - - created_at - title: State - description: 'Represents a state in the agent system. A state is associated - uniquely with a task and an agent. - - - This entity is used to store states in MongoDB, with each state - - associated with a specific task and agent. The combination of task_id and - agent_id is globally unique. - - - The state is a dictionary of arbitrary data.' - StateEntity: - properties: - id: - anyOf: - - type: string - - type: 'null' - title: Id - description: The task state's unique id - task_id: - type: string - title: Task Id - description: ID of the task this state belongs to. The combination of task_id - and agent_id is globally unique. - agent_id: - type: string - title: Agent Id - description: ID of the agent this state belongs to. The combination of task_id - and agent_id is globally unique. - state: - additionalProperties: true - type: object - title: State - description: The state object that contains arbitrary data - created_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Created At - description: The timestamp when the state was created - updated_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Updated At - description: The timestamp when the state was last updated - type: object - required: - - task_id - - agent_id - - state - title: StateEntity - description: 'Represents a state in the agent system. A state is associated - uniquely with a task and an agent. - - - This entity is used to store states in MongoDB, with each state - - associated with a specific task and agent. The combination of task_id and - agent_id is globally unique. - - - The state is a dictionary of arbitrary data.' - StreamTaskMessageDelta: - properties: - type: - type: string - const: delta - title: Type - default: delta - index: - anyOf: - - type: integer - - type: 'null' - title: Index - parent_task_message: - anyOf: - - $ref: '#/components/schemas/TaskMessage' - - type: 'null' - delta: - anyOf: - - $ref: '#/components/schemas/TaskMessageDelta' - - type: 'null' - type: object - title: StreamTaskMessageDelta - description: Event for streaming chunks of content - StreamTaskMessageDone: - properties: - type: - type: string - const: done - title: Type - default: done - index: - anyOf: - - type: integer - - type: 'null' - title: Index - parent_task_message: - anyOf: - - $ref: '#/components/schemas/TaskMessage' - - type: 'null' - type: object - title: StreamTaskMessageDone - description: Event for indicating the task is done - StreamTaskMessageFull: - properties: - type: - type: string - const: full - title: Type - default: full - index: - anyOf: - - type: integer - - type: 'null' - title: Index - parent_task_message: - anyOf: - - $ref: '#/components/schemas/TaskMessage' - - type: 'null' - content: - $ref: '#/components/schemas/TaskMessageContent' - type: object - required: - - content - title: StreamTaskMessageFull - description: Event for streaming the full content - StreamTaskMessageStart: - properties: - type: - type: string - const: start - title: Type - default: start - index: - anyOf: - - type: integer - - type: 'null' - title: Index - parent_task_message: - anyOf: - - $ref: '#/components/schemas/TaskMessage' - - type: 'null' - content: - $ref: '#/components/schemas/TaskMessageContent' - type: object - required: - - content - title: StreamTaskMessageStart - description: Event for starting a streaming message - Task: - properties: - id: - type: string - title: Unique Task ID - name: - anyOf: - - type: string - - type: 'null' - title: Unique name of the task - status: - anyOf: - - $ref: '#/components/schemas/TaskStatus' - - type: 'null' - title: The current status of the task - status_reason: - anyOf: - - type: string - - type: 'null' - title: The reason for the current task status - created_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: The timestamp when the task was created - updated_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: The timestamp when the task was last updated - cleaned_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: The timestamp when the task's content was cleaned for retention compliance; - null when active - params: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Task parameters - task_metadata: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Task metadata - type: object - required: - - id - title: Task - TaskMessage: - properties: - id: - anyOf: - - type: string - - type: 'null' - title: Id - description: The task message's unique id - task_id: - type: string - title: Task Id - description: ID of the task this message belongs to - content: - $ref: '#/components/schemas/TaskMessageContent' - description: The content of the message. This content is not OpenAI compatible. - These are messages that are meant to be displayed to the user. - streaming_status: - anyOf: - - type: string - enum: - - IN_PROGRESS - - DONE - - type: 'null' - title: In case of streaming, this indicates whether the message is still - being streamed or has been completed - created_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Created At - description: The timestamp when the message was created - updated_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Updated At - description: The timestamp when the message was last updated - type: object - required: - - task_id - - content - title: TaskMessage - description: 'Represents a message in the agent system. - - - This entity is used to store messages in MongoDB, with each message - - associated with a specific task.' - TaskMessageContent: - oneOf: - - $ref: '#/components/schemas/TextContent' - - $ref: '#/components/schemas/ReasoningContent' - - $ref: '#/components/schemas/DataContent' - - $ref: '#/components/schemas/ToolRequestContent' - - $ref: '#/components/schemas/ToolResponseContent' - title: TaskMessageContent - discriminator: - propertyName: type - mapping: - data: '#/components/schemas/DataContent' - reasoning: '#/components/schemas/ReasoningContent' - text: '#/components/schemas/TextContent' - tool_request: '#/components/schemas/ToolRequestContent' - tool_response: '#/components/schemas/ToolResponseContent' - TaskMessageDelta: - oneOf: - - $ref: '#/components/schemas/TextDelta' - - $ref: '#/components/schemas/DataDelta' - - $ref: '#/components/schemas/ToolRequestDelta' - - $ref: '#/components/schemas/ToolResponseDelta' - - $ref: '#/components/schemas/ReasoningSummaryDelta' - - $ref: '#/components/schemas/ReasoningContentDelta' - title: TaskMessageDelta - discriminator: - propertyName: type - mapping: - data: '#/components/schemas/DataDelta' - reasoning_content: '#/components/schemas/ReasoningContentDelta' - reasoning_summary: '#/components/schemas/ReasoningSummaryDelta' - text: '#/components/schemas/TextDelta' - tool_request: '#/components/schemas/ToolRequestDelta' - tool_response: '#/components/schemas/ToolResponseDelta' - TaskMessageEntity: - properties: - id: - anyOf: - - type: string - - type: 'null' - title: Id - description: The task message's unique id - task_id: - type: string - title: Task Id - description: ID of the task this message belongs to - content: - oneOf: - - $ref: '#/components/schemas/TextContentEntity' - - $ref: '#/components/schemas/DataContentEntity' - - $ref: '#/components/schemas/ToolRequestContentEntity' - - $ref: '#/components/schemas/ToolResponseContentEntity' - - $ref: '#/components/schemas/ReasoningContentEntity' - title: Content - description: The content of the message. This content is not OpenAI compatible. - These are messages that are meant to be displayed to the user. - discriminator: - propertyName: type - mapping: - data: '#/components/schemas/DataContentEntity' - reasoning: '#/components/schemas/ReasoningContentEntity' - text: '#/components/schemas/TextContentEntity' - tool_request: '#/components/schemas/ToolRequestContentEntity' - tool_response: '#/components/schemas/ToolResponseContentEntity' - streaming_status: - anyOf: - - type: string - enum: - - IN_PROGRESS - - DONE - - type: 'null' - title: In case of streaming, this indicates whether the message is still - being streamed or has been completed - created_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Created At - description: The timestamp when the message was created - updated_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Updated At - description: The timestamp when the message was last updated - type: object - required: - - task_id - - content - title: TaskMessageEntity - description: 'Represents a message in the agent system. - - - This entity is used to store messages in MongoDB, with each message - - associated with a specific task.' - TaskMessageUpdate: - oneOf: - - $ref: '#/components/schemas/StreamTaskMessageStart' - - $ref: '#/components/schemas/StreamTaskMessageDelta' - - $ref: '#/components/schemas/StreamTaskMessageFull' - - $ref: '#/components/schemas/StreamTaskMessageDone' - title: TaskMessageUpdate - discriminator: - propertyName: type - mapping: - delta: '#/components/schemas/StreamTaskMessageDelta' - done: '#/components/schemas/StreamTaskMessageDone' - full: '#/components/schemas/StreamTaskMessageFull' - start: '#/components/schemas/StreamTaskMessageStart' - TaskRelationships: - type: string - enum: - - agents - title: TaskRelationships - description: Task relationships that can be loaded - TaskResponse: - properties: - id: - type: string - title: Unique Task ID - name: - anyOf: - - type: string - - type: 'null' - title: Unique name of the task - status: - anyOf: - - $ref: '#/components/schemas/TaskStatus' - - type: 'null' - title: The current status of the task - status_reason: - anyOf: - - type: string - - type: 'null' - title: The reason for the current task status - created_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: The timestamp when the task was created - updated_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: The timestamp when the task was last updated - cleaned_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: The timestamp when the task's content was cleaned for retention compliance; - null when active - params: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Task parameters - task_metadata: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Task metadata - agents: - anyOf: - - items: - $ref: '#/components/schemas/Agent' - type: array - - type: 'null' - title: Agents associated with this task (only populated when 'agent' view - is requested) - type: object - required: - - id - title: TaskResponse - description: Task response model with optional related data based on relationships - TaskStatus: - type: string - enum: - - CANCELED - - COMPLETED - - FAILED - - RUNNING - - TERMINATED - - TIMED_OUT - - DELETED - title: TaskStatus - TaskStatusReasonRequest: - properties: - reason: - anyOf: - - type: string - - type: 'null' - title: Optional reason for the status change - type: object - title: TaskStatusReasonRequest - TextContent: - properties: - type: - type: string - const: text - title: Type - description: The type of the message, in this case `text`. - default: text - author: - $ref: '#/components/schemas/MessageAuthor' - description: The role of the messages author, in this case `system`, `user`, - `assistant`, or `tool`. - style: - $ref: '#/components/schemas/MessageStyle' - description: The style of the message. This is used by the client to determine - how to display the message. - default: static - format: - $ref: '#/components/schemas/TextFormat' - description: The format of the message. This is used by the client to determine - how to display the message. - default: plain - content: - type: string - title: Content - description: The contents of the text message. - attachments: - anyOf: - - items: - $ref: '#/components/schemas/FileAttachment' - type: array - - type: 'null' - title: Attachments - description: Optional list of file attachments with structured metadata. - type: object - required: - - author - - content - title: TextContent - TextContentEntity: - properties: - type: - type: string - const: text - title: Type - description: The type of the message, in this case `text`. - default: text - author: - $ref: '#/components/schemas/MessageAuthor' - description: The role of the messages author, in this case `system`, `user`, - `assistant`, or `tool`. - style: - $ref: '#/components/schemas/MessageStyle' - description: The style of the message. This is used by the client to determine - how to display the message. - default: static - format: - $ref: '#/components/schemas/TextFormat' - description: The format of the message. This is used by the client to determine - how to display the message. - default: plain - content: - type: string - title: Content - description: The contents of the text message. - attachments: - anyOf: - - items: - $ref: '#/components/schemas/FileAttachmentEntity' - type: array - - type: 'null' - title: Attachments - description: Optional list of file attachments with structured metadata. - type: object - required: - - author - - content - title: TextContentEntity - TextDelta: - properties: - type: - type: string - const: text - title: Type - default: text - text_delta: - anyOf: - - type: string - - type: 'null' - title: Text Delta - default: '' - type: object - title: TextDelta - description: Delta for text updates - TextFormat: - type: string - enum: - - markdown - - plain - - code - title: TextFormat - ToolRequestContent: - properties: - type: - type: string - const: tool_request - title: Type - description: The type of the message, in this case `tool_request`. - default: tool_request - author: - $ref: '#/components/schemas/MessageAuthor' - description: The role of the messages author, in this case `system`, `user`, - `assistant`, or `tool`. - style: - $ref: '#/components/schemas/MessageStyle' - description: The style of the message. This is used by the client to determine - how to display the message. - default: static - tool_call_id: - type: string - title: Tool Call Id - description: The ID of the tool call that is being requested. - name: - type: string - title: Name - description: The name of the tool that is being requested. - arguments: - additionalProperties: true - type: object - title: Arguments - description: The arguments to the tool. - type: object - required: - - author - - tool_call_id - - name - - arguments - title: ToolRequestContent - ToolRequestContentEntity: - properties: - type: - type: string - const: tool_request - title: Type - description: The type of the message, in this case `tool_request`. - default: tool_request - author: - $ref: '#/components/schemas/MessageAuthor' - description: The role of the messages author, in this case `system`, `user`, - `assistant`, or `tool`. - style: - $ref: '#/components/schemas/MessageStyle' - description: The style of the message. This is used by the client to determine - how to display the message. - default: static - tool_call_id: - type: string - title: Tool Call Id - description: The ID of the tool call that is being requested. - name: - type: string - title: Name - description: The name of the tool that is being requested. - arguments: - additionalProperties: true - type: object - title: Arguments - description: The arguments to the tool. - type: object - required: - - author - - tool_call_id - - name - - arguments - title: ToolRequestContentEntity - ToolRequestDelta: - properties: - type: - type: string - const: tool_request - title: Type - default: tool_request - tool_call_id: - type: string - title: Tool Call Id - name: - type: string - title: Name - arguments_delta: - anyOf: - - type: string - - type: 'null' - title: Arguments Delta - default: '' - type: object - required: - - tool_call_id - - name - title: ToolRequestDelta - description: Delta for tool request updates - ToolResponseContent: - properties: - type: - type: string - const: tool_response - title: Type - description: The type of the message, in this case `tool_response`. - default: tool_response - author: - $ref: '#/components/schemas/MessageAuthor' - description: The role of the messages author, in this case `system`, `user`, - `assistant`, or `tool`. - style: - $ref: '#/components/schemas/MessageStyle' - description: The style of the message. This is used by the client to determine - how to display the message. - default: static - tool_call_id: - type: string - title: Tool Call Id - description: The ID of the tool call that is being responded to. - name: - type: string - title: Name - description: The name of the tool that is being responded to. - content: - title: Content - description: The result of the tool. - type: object - required: - - author - - tool_call_id - - name - - content - title: ToolResponseContent - ToolResponseContentEntity: - properties: - type: - type: string - const: tool_response - title: Type - description: The type of the message, in this case `tool_response`. - default: tool_response - author: - $ref: '#/components/schemas/MessageAuthor' - description: The role of the messages author, in this case `system`, `user`, - `assistant`, or `tool`. - style: - $ref: '#/components/schemas/MessageStyle' - description: The style of the message. This is used by the client to determine - how to display the message. - default: static - tool_call_id: - type: string - title: Tool Call Id - description: The ID of the tool call that is being responded to. - name: - type: string - title: Name - description: The name of the tool that is being responded to. - content: - title: Content - description: The result of the tool. - type: object - required: - - author - - tool_call_id - - name - - content - title: ToolResponseContentEntity - ToolResponseDelta: - properties: - type: - type: string - const: tool_response - title: Type - default: tool_response - tool_call_id: - type: string - title: Tool Call Id - name: - type: string - title: Name - content_delta: - anyOf: - - type: string - - type: 'null' - title: Content Delta - default: '' - type: object - required: - - tool_call_id - - name - title: ToolResponseDelta - description: Delta for tool response updates - UnpauseScheduleRequest: - properties: - note: - anyOf: - - type: string - - type: 'null' - title: Note - description: Optional note explaining why the schedule was unpaused - type: object - title: UnpauseScheduleRequest - description: Request model for unpausing a schedule - UpdateAgentTaskTrackerRequest: - properties: - last_processed_event_id: - anyOf: - - type: string - - type: 'null' - title: Last Processed Event Id - description: The most recent processed event ID (omit to leave unchanged) - status: - anyOf: - - type: string - - type: 'null' - title: Status - description: Processing status - status_reason: - anyOf: - - type: string - - type: 'null' - title: Status Reason - description: Optional status reason - type: object - title: UpdateAgentTaskTrackerRequest - description: Request model for updating an agent task tracker. - UpdateSpanRequest: - properties: - trace_id: - anyOf: - - type: string - - type: 'null' - title: The trace ID for this span - description: Unique identifier for the trace this span belongs to - task_id: - anyOf: - - type: string - - type: 'null' - title: The task ID this span is associated with - description: ID of the task this span belongs to - parent_id: - anyOf: - - type: string - - type: 'null' - title: The parent span ID if this is a child span - description: ID of the parent span if this is a child span in a trace - name: - anyOf: - - type: string - - type: 'null' - title: The name of the span - description: Name that describes what operation this span represents - start_time: - anyOf: - - type: string - format: date-time - - type: 'null' - title: The start time of the span - description: The time the span started - end_time: - anyOf: - - type: string - format: date-time - - type: 'null' - title: The end time of the span - description: The time the span ended - input: - anyOf: - - additionalProperties: true - type: object - - items: - additionalProperties: true - type: object - type: array - - type: 'null' - title: The input data for the span - description: Input parameters or data for the operation - output: - anyOf: - - additionalProperties: true - type: object - - items: - additionalProperties: true - type: object - type: array - - type: 'null' - title: The output data from the span - description: Output data resulting from the operation - data: - anyOf: - - additionalProperties: true - type: object - - items: - additionalProperties: true - type: object - type: array - - type: 'null' - title: Additional data associated with the span - description: Any additional metadata or context for the span - type: object - title: UpdateSpanRequest - UpdateStateRequest: - properties: - state: - additionalProperties: true - type: object - title: The state to update the state with. - type: object - required: - - state - title: UpdateStateRequest - UpdateTaskMessageRequest: - properties: - task_id: - type: string - title: The unique id of the task to update the message of - content: - $ref: '#/components/schemas/TaskMessageContent' - title: The message to update the message with. - streaming_status: - anyOf: - - type: string - enum: - - IN_PROGRESS - - DONE - - type: 'null' - title: The streaming status of the message - type: object - required: - - task_id - - content - title: UpdateTaskMessageRequest - UpdateTaskRequest: - properties: - task_metadata: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: If provided, replaces task_metadata with this value - type: object - title: UpdateTaskRequest - ValidationError: - properties: - loc: - items: - anyOf: - - type: string - - type: integer - type: array - title: Location - msg: - type: string - title: Message - type: - type: string - title: Error Type - input: - title: Input - ctx: - type: object - title: Context - type: object - required: - - loc - - msg - - type - title: ValidationError - WriteData: - properties: - task_id: - type: string - title: Task ID - idx: - type: integer - title: Write index - channel: - type: string - title: Channel name - type: - anyOf: - - type: string - - type: 'null' - title: Serialization type tag - blob: - type: string - title: Base64-encoded binary data - task_path: - type: string - title: Task path - default: '' - type: object - required: - - task_id - - idx - - channel - - blob - title: WriteData - WriteResponse: - properties: - task_id: - type: string - title: Task Id - idx: - type: integer - title: Idx - channel: - type: string - title: Channel - type: - anyOf: - - type: string - - type: 'null' - title: Type - blob: - anyOf: - - type: string - - type: 'null' - title: Blob - type: object - required: - - task_id - - idx - - channel - title: WriteResponse diff --git a/tests/compat/server_specs/manifest.json b/tests/compat/server_specs/manifest.json deleted file mode 100644 index 986650583..000000000 --- a/tests/compat/server_specs/manifest.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "description": "Supported server OpenAPI contract window for SDK request-compatibility. Each file is agentex/openapi.yaml from scaleapi/scale-agentex at the pinned sha. Advance 'min-supported' as the oldest deployed server moves forward, then run refresh_specs.py.", - "source_repo": "scaleapi/scale-agentex", - "source_path": "agentex/openapi.yaml", - "specs": [ - { - "label": "current", - "file": "current.yaml", - "sha": "ab469df91bba043ea25356c89147d29f7df03bad", - "note": "scale-agentex main at vendoring time" - }, - { - "label": "min-supported", - "file": "min-supported.yaml", - "sha": "39e3a7118d55b7db4c01f6ee14e68a8167bb001d", - "note": "pre-#278; oldest contract still in the field. UpdateStateRequest requires task_id/agent_id in the body โ€” the contract the 0.13.0 client broke." - } - ] -} diff --git a/tests/compat/server_specs/min-supported.yaml b/tests/compat/server_specs/min-supported.yaml deleted file mode 100644 index cbcf2ca45..000000000 --- a/tests/compat/server_specs/min-supported.yaml +++ /dev/null @@ -1,6761 +0,0 @@ -openapi: 3.1.0 -info: - title: Agentex API - version: 0.1.0 -paths: - /agents/{agent_id}: - get: - tags: - - Agents - summary: Get Agent by ID - description: Get an agent by its unique ID. - operationId: get_agent_by_id_agents__agent_id__get - parameters: - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Agent' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - delete: - tags: - - Agents - summary: Delete Agent by ID - description: Delete an agent by its unique ID. - operationId: delete_agent_by_id_agents__agent_id__delete - parameters: - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/name/{agent_name}: - get: - tags: - - Agents - summary: Get Agent by Name - description: Get an agent by its unique name. - operationId: get_agent_by_name_agents_name__agent_name__get - parameters: - - name: agent_name - in: path - required: true - schema: - type: string - title: Agent Name - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Agent' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - delete: - tags: - - Agents - summary: Delete Agent by Name - description: Delete an agent by its unique name. - operationId: delete_agent_by_name_agents_name__agent_name__delete - parameters: - - name: agent_name - in: path - required: true - schema: - type: string - title: Agent Name - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents: - get: - tags: - - Agents - summary: List Agents - description: List all registered agents, optionally filtered by query parameters. - operationId: list_agents_agents_get - parameters: - - name: task_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: Task ID - title: Task Id - description: Task ID - - name: limit - in: query - required: false - schema: - type: integer - minimum: 1 - description: Limit - default: 50 - title: Limit - description: Limit - - name: page_number - in: query - required: false - schema: - type: integer - minimum: 1 - description: Page number - default: 1 - title: Page Number - description: Page number - - name: order_by - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: Field to order by - title: Order By - description: Field to order by - - name: order_direction - in: query - required: false - schema: - type: string - description: Order direction (asc or desc) - default: desc - title: Order Direction - description: Order direction (asc or desc) - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/Agent' - title: Response List Agents Agents Get - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/register: - post: - tags: - - Agents - summary: Register Agent - description: Register a new agent or update an existing one. - operationId: register_agent_agents_register_post - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/RegisterAgentRequest' - required: true - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/RegisterAgentResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/register-build: - post: - tags: - - Agents - summary: Register Build - description: Register an agent at build time, before it is deployed, so it can - be permissioned and shared prior to deploy. Idempotent by name. - operationId: register_build_agents_register_build_post - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/RegisterBuildRequest' - required: true - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Agent' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/forward/name/{agent_name}/{path}: - get: - tags: - - Agents - summary: Forward GET request to agent by name - description: Forward a GET request to an agent by its name. - operationId: forward_get_request_to_agent_agents_forward_name__agent_name___path__get - parameters: - - name: agent_name - in: path - required: true - schema: - type: string - title: Agent Name - - name: path - in: path - required: true - schema: - type: string - title: Path - responses: - '200': - description: Successful Response - content: - application/json: - schema: {} - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - post: - tags: - - Agents - summary: Forward POST request to agent by name - description: Forward a POST request to an agent by its name. - operationId: forward_post_request_to_agent_agents_forward_name__agent_name___path__post - parameters: - - name: agent_name - in: path - required: true - schema: - type: string - title: Agent Name - - name: path - in: path - required: true - schema: - type: string - title: Path - responses: - '200': - description: Successful Response - content: - application/json: - schema: {} - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/{agent_id}/rpc: - post: - tags: - - Agents - summary: Handle Agent RPC by ID - description: Handle JSON-RPC requests for an agent by its unique ID. - operationId: handle_agent_rpc_by_id_agents__agent_id__rpc_post - parameters: - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/AgentRPCRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/AgentRPCResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/name/{agent_name}/rpc: - post: - tags: - - Agents - summary: Handle Agent RPC by Name - description: Handle JSON-RPC requests for an agent by its unique name. - operationId: handle_agent_rpc_by_name_agents_name__agent_name__rpc_post - parameters: - - name: agent_name - in: path - required: true - schema: - type: string - title: Agent Name - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/AgentRPCRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/AgentRPCResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/{task_id}: - get: - tags: - - Tasks - summary: Get Task by ID - description: Get a task by its unique ID. - operationId: get_task_tasks__task_id__get - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - - name: relationships - in: query - required: false - schema: - type: array - items: - $ref: '#/components/schemas/TaskRelationships' - title: Relationships - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/TaskResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - delete: - tags: - - Tasks - summary: Delete Task by ID - description: Delete a task by its unique ID. - operationId: delete_task_tasks__task_id__delete - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - put: - tags: - - Tasks - summary: Update Task by ID - description: Update mutable fields for a task by its unique ID. - operationId: update_task_tasks__task_id__put - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateTaskRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/name/{task_name}: - get: - tags: - - Tasks - summary: Get Task by Name - description: Get a task by its unique name. - operationId: get_task_by_name_tasks_name__task_name__get - parameters: - - name: task_name - in: path - required: true - schema: - type: string - title: Task Name - - name: relationships - in: query - required: false - schema: - type: array - items: - $ref: '#/components/schemas/TaskRelationships' - title: Relationships - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/TaskResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - delete: - tags: - - Tasks - summary: Delete Task by Name - description: Delete a task by its unique name. - operationId: delete_task_by_name_tasks_name__task_name__delete - parameters: - - name: task_name - in: path - required: true - schema: - type: string - title: Task Name - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - put: - tags: - - Tasks - summary: Update Task by Name - description: Update mutable fields for a task by its unique Name. - operationId: update_task_by_name_tasks_name__task_name__put - parameters: - - name: task_name - in: path - required: true - schema: - type: string - title: Task Name - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateTaskRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks: - get: - tags: - - Tasks - summary: List Tasks - description: List all tasks. - operationId: list_tasks_tasks_get - parameters: - - name: agent_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Agent Id - - name: agent_name - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Agent Name - - name: status - in: query - required: false - schema: - anyOf: - - $ref: '#/components/schemas/TaskStatus' - - type: 'null' - description: Filter tasks by status (e.g. RUNNING, COMPLETED). - title: Status - description: Filter tasks by status (e.g. RUNNING, COMPLETED). - - name: task_metadata - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: 'JSON-encoded object used to filter tasks via JSONB containment. - Example: {"created_by_user_id": "abc-123"}.' - title: Task Metadata - description: 'JSON-encoded object used to filter tasks via JSONB containment. - Example: {"created_by_user_id": "abc-123"}.' - - name: limit - in: query - required: false - schema: - type: integer - default: 50 - title: Limit - - name: page_number - in: query - required: false - schema: - type: integer - default: 1 - title: Page Number - - name: order_by - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Order By - - name: order_direction - in: query - required: false - schema: - type: string - default: desc - title: Order Direction - - name: relationships - in: query - required: false - schema: - type: array - items: - $ref: '#/components/schemas/TaskRelationships' - title: Relationships - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/TaskResponse' - title: Response List Tasks Tasks Get - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/{task_id}/complete: - post: - tags: - - Tasks - summary: Complete Task - description: Mark a running task as completed. - operationId: complete_task_tasks__task_id__complete_post - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - requestBody: - content: - application/json: - schema: - anyOf: - - $ref: '#/components/schemas/TaskStatusReasonRequest' - - type: 'null' - title: Request - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/{task_id}/fail: - post: - tags: - - Tasks - summary: Fail Task - description: Mark a running task as failed. - operationId: fail_task_tasks__task_id__fail_post - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - requestBody: - content: - application/json: - schema: - anyOf: - - $ref: '#/components/schemas/TaskStatusReasonRequest' - - type: 'null' - title: Request - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/{task_id}/cancel: - post: - tags: - - Tasks - summary: Cancel Task - description: Mark a running task as canceled. - operationId: cancel_task_tasks__task_id__cancel_post - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - requestBody: - content: - application/json: - schema: - anyOf: - - $ref: '#/components/schemas/TaskStatusReasonRequest' - - type: 'null' - title: Request - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/{task_id}/terminate: - post: - tags: - - Tasks - summary: Terminate Task - description: Mark a running task as terminated. - operationId: terminate_task_tasks__task_id__terminate_post - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - requestBody: - content: - application/json: - schema: - anyOf: - - $ref: '#/components/schemas/TaskStatusReasonRequest' - - type: 'null' - title: Request - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/{task_id}/timeout: - post: - tags: - - Tasks - summary: Timeout Task - description: Mark a running task as timed out. - operationId: timeout_task_tasks__task_id__timeout_post - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - requestBody: - content: - application/json: - schema: - anyOf: - - $ref: '#/components/schemas/TaskStatusReasonRequest' - - type: 'null' - title: Request - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Task' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/{task_id}/stream: - get: - tags: - - Tasks - summary: Stream Task Events by ID - description: Stream events for a task by its unique ID. - operationId: stream_task_events_tasks__task_id__stream_get - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: {} - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/name/{task_name}/stream: - get: - tags: - - Tasks - summary: Stream Task Events by Name - description: Stream events for a task by its unique name. - operationId: stream_task_events_by_name_tasks_name__task_name__stream_get - parameters: - - name: task_name - in: path - required: true - schema: - type: string - title: Task Name - responses: - '200': - description: Successful Response - content: - application/json: - schema: {} - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/{task_id}/query/{query_name}: - get: - tags: - - Tasks - summary: Query Task Workflow - description: Query a Temporal workflow associated with a task for its current - state. - operationId: query_task_workflow_tasks__task_id__query__query_name__get - parameters: - - name: query_name - in: path - required: true - schema: - type: string - title: Query Name - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: object - additionalProperties: true - title: Response Query Task Workflow Tasks Task Id Query Query Name Get - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /messages/batch: - put: - tags: - - Messages - summary: Batch Update Messages - operationId: batch_update_messages_messages_batch_put - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/BatchUpdateTaskMessagesRequest' - required: true - responses: - '200': - description: Successful Response - content: - application/json: - schema: - items: - $ref: '#/components/schemas/TaskMessage' - type: array - title: Response Batch Update Messages Messages Batch Put - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - post: - tags: - - Messages - summary: Batch Create Messages - operationId: batch_create_messages_messages_batch_post - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/BatchCreateTaskMessagesRequest' - required: true - responses: - '200': - description: Successful Response - content: - application/json: - schema: - items: - $ref: '#/components/schemas/TaskMessage' - type: array - title: Response Batch Create Messages Messages Batch Post - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /messages: - post: - tags: - - Messages - summary: Create Message - operationId: create_message_messages_post - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateTaskMessageRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/TaskMessage' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - get: - tags: - - Messages - summary: List Messages - description: 'List messages for a task with offset-based pagination. - - - For cursor-based pagination with infinite scroll support, use /messages/paginated.' - operationId: list_messages_messages_get - parameters: - - name: limit - in: query - required: false - schema: - type: integer - default: 50 - title: Limit - - name: page_number - in: query - required: false - schema: - type: integer - default: 1 - title: Page Number - - name: order_by - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Order By - - name: order_direction - in: query - required: false - schema: - type: string - default: desc - title: Order Direction - - name: filters - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: "JSON-encoded array of TaskMessageEntityFilter objects.\n\n\ - Schema: {\n \"$defs\": {\n \"DataContentEntityOptional\": {\n \ - \ \"properties\": {\n \"type\": {\n \"anyOf\": [\n \ - \ {\n \"const\": \"data\",\n \"type\"\ - : \"string\"\n },\n {\n \"type\": \"\ - null\"\n }\n ],\n \"default\": null,\n \ - \ \"description\": \"The type of the message, in this case `data`.\"\ - ,\n \"title\": \"Type\"\n },\n \"author\": {\n\ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The role of the messages author, in this case `system`,\ - \ `user`, `assistant`, or `tool`.\"\n },\n \"style\": {\n\ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The style of the message. This is used by the client\ - \ to determine how to display the message.\"\n },\n \"data\"\ - : {\n \"anyOf\": [\n {\n \"additionalProperties\"\ - : true,\n \"type\": \"object\"\n },\n \ - \ {\n \"type\": \"null\"\n }\n ],\n\ - \ \"default\": null,\n \"description\": \"The contents\ - \ of the data message.\",\n \"title\": \"Data\"\n }\n\ - \ },\n \"title\": \"DataContentEntityOptional\",\n \"type\"\ - : \"object\"\n },\n \"FileAttachmentEntity\": {\n \"description\"\ - : \"Represents a file attachment in messages.\",\n \"properties\"\ - : {\n \"file_id\": {\n \"description\": \"The unique ID\ - \ of the attached file\",\n \"title\": \"File Id\",\n \ - \ \"type\": \"string\"\n },\n \"name\": {\n \"\ - description\": \"The name of the file\",\n \"title\": \"Name\"\ - ,\n \"type\": \"string\"\n },\n \"size\": {\n \ - \ \"description\": \"The size of the file in bytes\",\n \ - \ \"title\": \"Size\",\n \"type\": \"integer\"\n },\n\ - \ \"type\": {\n \"description\": \"The MIME type or content\ - \ type of the file\",\n \"title\": \"Type\",\n \"type\"\ - : \"string\"\n }\n },\n \"required\": [\n \"file_id\"\ - ,\n \"name\",\n \"size\",\n \"type\"\n ],\n\ - \ \"title\": \"FileAttachmentEntity\",\n \"type\": \"object\"\ - \n },\n \"MessageAuthor\": {\n \"enum\": [\n \"user\"\ - ,\n \"agent\"\n ],\n \"title\": \"MessageAuthor\",\n\ - \ \"type\": \"string\"\n },\n \"MessageStyle\": {\n \"\ - enum\": [\n \"static\",\n \"active\"\n ],\n \"\ - title\": \"MessageStyle\",\n \"type\": \"string\"\n },\n \"\ - ReasoningContentEntityOptional\": {\n \"properties\": {\n \ - \ \"type\": {\n \"anyOf\": [\n {\n \"\ - const\": \"reasoning\",\n \"type\": \"string\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The type of the message, in this case `reasoning`.\",\n \"\ - title\": \"Type\"\n },\n \"author\": {\n \"anyOf\"\ - : [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\n\ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The role of the messages author, in this case `system`,\ - \ `user`, `assistant`, or `tool`.\"\n },\n \"style\": {\n\ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The style of the message. This is used by the client\ - \ to determine how to display the message.\"\n },\n \"summary\"\ - : {\n \"anyOf\": [\n {\n \"items\": {\n\ - \ \"type\": \"string\"\n },\n \ - \ \"type\": \"array\"\n },\n {\n \"\ - type\": \"null\"\n }\n ],\n \"default\":\ - \ null,\n \"description\": \"A list of short reasoning summaries\"\ - ,\n \"title\": \"Summary\"\n },\n \"content\":\ - \ {\n \"anyOf\": [\n {\n \"items\": {\n\ - \ \"type\": \"string\"\n },\n \ - \ \"type\": \"array\"\n },\n {\n \"\ - type\": \"null\"\n }\n ],\n \"default\":\ - \ null,\n \"description\": \"The reasoning content or chain-of-thought\ - \ text\",\n \"title\": \"Content\"\n }\n },\n \ - \ \"title\": \"ReasoningContentEntityOptional\",\n \"type\": \"\ - object\"\n },\n \"TextContentEntityOptional\": {\n \"properties\"\ - : {\n \"type\": {\n \"anyOf\": [\n {\n \ - \ \"const\": \"text\",\n \"type\": \"string\"\n\ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The type of the message, in this case `text`.\",\n \ - \ \"title\": \"Type\"\n },\n \"author\": {\n \ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The role of the messages author, in this case `system`,\ - \ `user`, `assistant`, or `tool`.\"\n },\n \"style\": {\n\ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The style of the message. This is used by the client\ - \ to determine how to display the message.\"\n },\n \"format\"\ - : {\n \"anyOf\": [\n {\n \"$ref\": \"\ - #/$defs/TextFormat\"\n },\n {\n \"\ - type\": \"null\"\n }\n ],\n \"default\":\ - \ null,\n \"description\": \"The format of the message. This\ - \ is used by the client to determine how to display the message.\"\n \ - \ },\n \"content\": {\n \"anyOf\": [\n \ - \ {\n \"type\": \"string\"\n },\n \ - \ {\n \"type\": \"null\"\n }\n ],\n\ - \ \"default\": null,\n \"description\": \"The contents\ - \ of the text message.\",\n \"title\": \"Content\"\n },\n\ - \ \"attachments\": {\n \"anyOf\": [\n {\n \ - \ \"items\": {\n \"$ref\": \"#/$defs/FileAttachmentEntity\"\ - \n },\n \"type\": \"array\"\n },\n\ - \ {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"\ - Optional list of file attachments with structured metadata.\",\n \ - \ \"title\": \"Attachments\"\n }\n },\n \"title\"\ - : \"TextContentEntityOptional\",\n \"type\": \"object\"\n },\n\ - \ \"TextFormat\": {\n \"enum\": [\n \"markdown\",\n \ - \ \"plain\",\n \"code\"\n ],\n \"title\": \"TextFormat\"\ - ,\n \"type\": \"string\"\n },\n \"ToolRequestContentEntityOptional\"\ - : {\n \"properties\": {\n \"type\": {\n \"anyOf\"\ - : [\n {\n \"const\": \"tool_request\",\n \ - \ \"type\": \"string\"\n },\n {\n \ - \ \"type\": \"null\"\n }\n ],\n \"\ - default\": null,\n \"description\": \"The type of the message,\ - \ in this case `tool_request`.\",\n \"title\": \"Type\"\n \ - \ },\n \"author\": {\n \"anyOf\": [\n {\n\ - \ \"$ref\": \"#/$defs/MessageAuthor\"\n },\n \ - \ {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"\ - The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\"\ - : [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\n\ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The style of the message. This is used by the client\ - \ to determine how to display the message.\"\n },\n \"tool_call_id\"\ - : {\n \"anyOf\": [\n {\n \"type\": \"\ - string\"\n },\n {\n \"type\": \"null\"\ - \n }\n ],\n \"default\": null,\n \ - \ \"description\": \"The ID of the tool call that is being requested.\"\ - ,\n \"title\": \"Tool Call Id\"\n },\n \"name\"\ - : {\n \"anyOf\": [\n {\n \"type\": \"\ - string\"\n },\n {\n \"type\": \"null\"\ - \n }\n ],\n \"default\": null,\n \ - \ \"description\": \"The name of the tool that is being requested.\"\ - ,\n \"title\": \"Name\"\n },\n \"arguments\": {\n\ - \ \"anyOf\": [\n {\n \"additionalProperties\"\ - : true,\n \"type\": \"object\"\n },\n \ - \ {\n \"type\": \"null\"\n }\n ],\n\ - \ \"default\": null,\n \"description\": \"The arguments\ - \ to the tool.\",\n \"title\": \"Arguments\"\n }\n \ - \ },\n \"title\": \"ToolRequestContentEntityOptional\",\n \ - \ \"type\": \"object\"\n },\n \"ToolResponseContentEntityOptional\"\ - : {\n \"properties\": {\n \"type\": {\n \"anyOf\"\ - : [\n {\n \"const\": \"tool_response\",\n \ - \ \"type\": \"string\"\n },\n {\n \ - \ \"type\": \"null\"\n }\n ],\n \"\ - default\": null,\n \"description\": \"The type of the message,\ - \ in this case `tool_response`.\",\n \"title\": \"Type\"\n \ - \ },\n \"author\": {\n \"anyOf\": [\n \ - \ {\n \"$ref\": \"#/$defs/MessageAuthor\"\n },\n\ - \ {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"\ - The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\"\ - : [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\n\ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The style of the message. This is used by the client\ - \ to determine how to display the message.\"\n },\n \"tool_call_id\"\ - : {\n \"anyOf\": [\n {\n \"type\": \"\ - string\"\n },\n {\n \"type\": \"null\"\ - \n }\n ],\n \"default\": null,\n \ - \ \"description\": \"The ID of the tool call that is being responded\ - \ to.\",\n \"title\": \"Tool Call Id\"\n },\n \"\ - name\": {\n \"anyOf\": [\n {\n \"type\"\ - : \"string\"\n },\n {\n \"type\": \"\ - null\"\n }\n ],\n \"default\": null,\n \ - \ \"description\": \"The name of the tool that is being responded\ - \ to.\",\n \"title\": \"Name\"\n },\n \"content\"\ - : {\n \"anyOf\": [\n {},\n {\n \ - \ \"type\": \"null\"\n }\n ],\n \"default\"\ - : null,\n \"description\": \"The result of the tool.\",\n \ - \ \"title\": \"Content\"\n }\n },\n \"title\":\ - \ \"ToolResponseContentEntityOptional\",\n \"type\": \"object\"\n\ - \ }\n },\n \"description\": \"Filter model for TaskMessage - all\ - \ fields optional for flexible filtering.\\n\\nThe `exclude` field determines\ - \ whether this filter is inclusionary or exclusionary.\\nWhen multiple\ - \ filters are provided:\\n- Inclusionary filters (exclude=False) are OR'd\ - \ together\\n- Exclusionary filters (exclude=True) are OR'd together and\ - \ negated with $nor\\n- The two groups are AND'd: (include1 OR include2)\ - \ AND NOT (exclude1 OR exclude2)\",\n \"properties\": {\n \"content\"\ - : {\n \"anyOf\": [\n {\n \"$ref\": \"#/$defs/ToolRequestContentEntityOptional\"\ - \n },\n {\n \"$ref\": \"#/$defs/DataContentEntityOptional\"\ - \n },\n {\n \"$ref\": \"#/$defs/TextContentEntityOptional\"\ - \n },\n {\n \"$ref\": \"#/$defs/ToolResponseContentEntityOptional\"\ - \n },\n {\n \"$ref\": \"#/$defs/ReasoningContentEntityOptional\"\ - \n },\n {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"Filter by message\ - \ content\",\n \"title\": \"Content\"\n },\n \"streaming_status\"\ - : {\n \"anyOf\": [\n {\n \"enum\": [\n \ - \ \"IN_PROGRESS\",\n \"DONE\"\n ],\n \"\ - type\": \"string\"\n },\n {\n \"type\": \"null\"\ - \n }\n ],\n \"default\": null,\n \"description\"\ - : \"Filter by streaming status\",\n \"title\": \"Streaming Status\"\ - \n },\n \"exclude\": {\n \"default\": false,\n \"description\"\ - : \"If true, this filter excludes matching messages\",\n \"title\"\ - : \"Exclude\",\n \"type\": \"boolean\"\n }\n },\n \"title\"\ - : \"TaskMessageEntityFilter\",\n \"type\": \"object\"\n}\n\nEach filter\ - \ can include:\n- `content`: Filter by message content (type, author,\ - \ data fields)\n- `streaming_status`: Filter by status (\"IN_PROGRESS\"\ - \ or \"DONE\")\n- `exclude`: If true, excludes matching messages (default:\ - \ false)\n\nMultiple filters are combined: inclusionary filters (exclude=false)\ - \ are OR'd together,\nexclusionary filters (exclude=true) are OR'd and\ - \ negated, then both groups are AND'd.\n" - title: Filters - description: "JSON-encoded array of TaskMessageEntityFilter objects.\n\nSchema:\ - \ {\n \"$defs\": {\n \"DataContentEntityOptional\": {\n \"properties\"\ - : {\n \"type\": {\n \"anyOf\": [\n {\n \ - \ \"const\": \"data\",\n \"type\": \"string\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The type of the message, in this case `data`.\",\n \"title\"\ - : \"Type\"\n },\n \"author\": {\n \"anyOf\": [\n\ - \ {\n \"$ref\": \"#/$defs/MessageAuthor\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ - \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The style of the message. This is used by the client to determine how\ - \ to display the message.\"\n },\n \"data\": {\n \ - \ \"anyOf\": [\n {\n \"additionalProperties\": true,\n\ - \ \"type\": \"object\"\n },\n {\n \ - \ \"type\": \"null\"\n }\n ],\n \"\ - default\": null,\n \"description\": \"The contents of the data\ - \ message.\",\n \"title\": \"Data\"\n }\n },\n \ - \ \"title\": \"DataContentEntityOptional\",\n \"type\": \"object\"\ - \n },\n \"FileAttachmentEntity\": {\n \"description\": \"Represents\ - \ a file attachment in messages.\",\n \"properties\": {\n \"\ - file_id\": {\n \"description\": \"The unique ID of the attached\ - \ file\",\n \"title\": \"File Id\",\n \"type\": \"string\"\ - \n },\n \"name\": {\n \"description\": \"The name\ - \ of the file\",\n \"title\": \"Name\",\n \"type\": \"\ - string\"\n },\n \"size\": {\n \"description\": \"\ - The size of the file in bytes\",\n \"title\": \"Size\",\n \ - \ \"type\": \"integer\"\n },\n \"type\": {\n \ - \ \"description\": \"The MIME type or content type of the file\",\n \ - \ \"title\": \"Type\",\n \"type\": \"string\"\n }\n\ - \ },\n \"required\": [\n \"file_id\",\n \"name\"\ - ,\n \"size\",\n \"type\"\n ],\n \"title\": \"FileAttachmentEntity\"\ - ,\n \"type\": \"object\"\n },\n \"MessageAuthor\": {\n \"\ - enum\": [\n \"user\",\n \"agent\"\n ],\n \"title\"\ - : \"MessageAuthor\",\n \"type\": \"string\"\n },\n \"MessageStyle\"\ - : {\n \"enum\": [\n \"static\",\n \"active\"\n ],\n\ - \ \"title\": \"MessageStyle\",\n \"type\": \"string\"\n },\n\ - \ \"ReasoningContentEntityOptional\": {\n \"properties\": {\n \ - \ \"type\": {\n \"anyOf\": [\n {\n \ - \ \"const\": \"reasoning\",\n \"type\": \"string\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The type of the message, in this case `reasoning`.\",\n \"\ - title\": \"Type\"\n },\n \"author\": {\n \"anyOf\"\ - : [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\n\ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ - \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The style of the message. This is used by the client to determine how\ - \ to display the message.\"\n },\n \"summary\": {\n \ - \ \"anyOf\": [\n {\n \"items\": {\n \ - \ \"type\": \"string\"\n },\n \"type\":\ - \ \"array\"\n },\n {\n \"type\": \"null\"\ - \n }\n ],\n \"default\": null,\n \"\ - description\": \"A list of short reasoning summaries\",\n \"title\"\ - : \"Summary\"\n },\n \"content\": {\n \"anyOf\":\ - \ [\n {\n \"items\": {\n \"type\"\ - : \"string\"\n },\n \"type\": \"array\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The reasoning content or chain-of-thought text\",\n \"title\"\ - : \"Content\"\n }\n },\n \"title\": \"ReasoningContentEntityOptional\"\ - ,\n \"type\": \"object\"\n },\n \"TextContentEntityOptional\"\ - : {\n \"properties\": {\n \"type\": {\n \"anyOf\":\ - \ [\n {\n \"const\": \"text\",\n \"\ - type\": \"string\"\n },\n {\n \"type\"\ - : \"null\"\n }\n ],\n \"default\": null,\n\ - \ \"description\": \"The type of the message, in this case `text`.\"\ - ,\n \"title\": \"Type\"\n },\n \"author\": {\n \ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ - \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The style of the message. This is used by the client to determine how\ - \ to display the message.\"\n },\n \"format\": {\n \ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/TextFormat\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The format of the message. This is used by the client to determine how\ - \ to display the message.\"\n },\n \"content\": {\n \ - \ \"anyOf\": [\n {\n \"type\": \"string\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The contents of the text message.\",\n \"title\": \"Content\"\ - \n },\n \"attachments\": {\n \"anyOf\": [\n \ - \ {\n \"items\": {\n \"$ref\": \"#/$defs/FileAttachmentEntity\"\ - \n },\n \"type\": \"array\"\n },\n\ - \ {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"Optional\ - \ list of file attachments with structured metadata.\",\n \"title\"\ - : \"Attachments\"\n }\n },\n \"title\": \"TextContentEntityOptional\"\ - ,\n \"type\": \"object\"\n },\n \"TextFormat\": {\n \"enum\"\ - : [\n \"markdown\",\n \"plain\",\n \"code\"\n \ - \ ],\n \"title\": \"TextFormat\",\n \"type\": \"string\"\n \ - \ },\n \"ToolRequestContentEntityOptional\": {\n \"properties\"\ - : {\n \"type\": {\n \"anyOf\": [\n {\n \ - \ \"const\": \"tool_request\",\n \"type\": \"string\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The type of the message, in this case `tool_request`.\",\n \ - \ \"title\": \"Type\"\n },\n \"author\": {\n \"anyOf\"\ - : [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\n\ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ - \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The style of the message. This is used by the client to determine how\ - \ to display the message.\"\n },\n \"tool_call_id\": {\n \ - \ \"anyOf\": [\n {\n \"type\": \"string\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The ID of the tool call that is being requested.\",\n \"title\"\ - : \"Tool Call Id\"\n },\n \"name\": {\n \"anyOf\"\ - : [\n {\n \"type\": \"string\"\n },\n\ - \ {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"The\ - \ name of the tool that is being requested.\",\n \"title\": \"\ - Name\"\n },\n \"arguments\": {\n \"anyOf\": [\n \ - \ {\n \"additionalProperties\": true,\n \ - \ \"type\": \"object\"\n },\n {\n \ - \ \"type\": \"null\"\n }\n ],\n \"default\"\ - : null,\n \"description\": \"The arguments to the tool.\",\n \ - \ \"title\": \"Arguments\"\n }\n },\n \"title\"\ - : \"ToolRequestContentEntityOptional\",\n \"type\": \"object\"\n \ - \ },\n \"ToolResponseContentEntityOptional\": {\n \"properties\"\ - : {\n \"type\": {\n \"anyOf\": [\n {\n \ - \ \"const\": \"tool_response\",\n \"type\": \"string\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The type of the message, in this case `tool_response`.\",\n \ - \ \"title\": \"Type\"\n },\n \"author\": {\n \"\ - anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ - \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The style of the message. This is used by the client to determine how\ - \ to display the message.\"\n },\n \"tool_call_id\": {\n \ - \ \"anyOf\": [\n {\n \"type\": \"string\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The ID of the tool call that is being responded to.\",\n \"\ - title\": \"Tool Call Id\"\n },\n \"name\": {\n \"\ - anyOf\": [\n {\n \"type\": \"string\"\n \ - \ },\n {\n \"type\": \"null\"\n }\n\ - \ ],\n \"default\": null,\n \"description\":\ - \ \"The name of the tool that is being responded to.\",\n \"title\"\ - : \"Name\"\n },\n \"content\": {\n \"anyOf\": [\n\ - \ {},\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The result of the tool.\",\n \"title\": \"Content\"\n \ - \ }\n },\n \"title\": \"ToolResponseContentEntityOptional\"\ - ,\n \"type\": \"object\"\n }\n },\n \"description\": \"Filter\ - \ model for TaskMessage - all fields optional for flexible filtering.\\\ - n\\nThe `exclude` field determines whether this filter is inclusionary or\ - \ exclusionary.\\nWhen multiple filters are provided:\\n- Inclusionary filters\ - \ (exclude=False) are OR'd together\\n- Exclusionary filters (exclude=True)\ - \ are OR'd together and negated with $nor\\n- The two groups are AND'd:\ - \ (include1 OR include2) AND NOT (exclude1 OR exclude2)\",\n \"properties\"\ - : {\n \"content\": {\n \"anyOf\": [\n {\n \"$ref\"\ - : \"#/$defs/ToolRequestContentEntityOptional\"\n },\n {\n\ - \ \"$ref\": \"#/$defs/DataContentEntityOptional\"\n },\n\ - \ {\n \"$ref\": \"#/$defs/TextContentEntityOptional\"\n\ - \ },\n {\n \"$ref\": \"#/$defs/ToolResponseContentEntityOptional\"\ - \n },\n {\n \"$ref\": \"#/$defs/ReasoningContentEntityOptional\"\ - \n },\n {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"Filter by message\ - \ content\",\n \"title\": \"Content\"\n },\n \"streaming_status\"\ - : {\n \"anyOf\": [\n {\n \"enum\": [\n \"\ - IN_PROGRESS\",\n \"DONE\"\n ],\n \"type\":\ - \ \"string\"\n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\": \"Filter\ - \ by streaming status\",\n \"title\": \"Streaming Status\"\n },\n\ - \ \"exclude\": {\n \"default\": false,\n \"description\": \"\ - If true, this filter excludes matching messages\",\n \"title\": \"\ - Exclude\",\n \"type\": \"boolean\"\n }\n },\n \"title\": \"TaskMessageEntityFilter\"\ - ,\n \"type\": \"object\"\n}\n\nEach filter can include:\n- `content`: Filter\ - \ by message content (type, author, data fields)\n- `streaming_status`:\ - \ Filter by status (\"IN_PROGRESS\" or \"DONE\")\n- `exclude`: If true,\ - \ excludes matching messages (default: false)\n\nMultiple filters are combined:\ - \ inclusionary filters (exclude=false) are OR'd together,\nexclusionary\ - \ filters (exclude=true) are OR'd and negated, then both groups are AND'd.\n" - examples: - single_filter: - summary: Filter by content type - value: '{"content": {"type": "text"}}' - multiple_types: - summary: Filter multiple content types (OR) - value: '[{"content": {"type": "text"}}, {"content": {"type": "data"}}]' - with_exclusion: - summary: Include data messages, exclude specific data types - value: '[{"content": {"type": "data"}}, {"content": {"data": {"type": - "error_report"}}, "exclude": true}]' - nested_data: - summary: Filter by nested data field - value: '{"content": {"data": {"type": "report_status_update"}}}' - - name: task_id - in: query - required: true - schema: - type: string - description: The task ID - title: Task Id - description: The task ID - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/TaskMessage' - title: Response List Messages Messages Get - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /messages/{message_id}: - put: - tags: - - Messages - summary: Update Message - operationId: update_message_messages__message_id__put - parameters: - - name: message_id - in: path - required: true - schema: - type: string - title: Message Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateTaskMessageRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/TaskMessage' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - get: - tags: - - Messages - summary: Get Message - operationId: get_message_messages__message_id__get - parameters: - - name: message_id - in: path - required: true - schema: - type: string - title: Message Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/TaskMessage' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /messages/paginated: - get: - tags: - - Messages - summary: List Messages Paginated - description: "List messages for a task with cursor-based pagination.\n\nThis\ - \ endpoint is designed for infinite scroll UIs where new messages may arrive\n\ - while paginating through older ones.\n\nArgs:\n task_id: The task ID to\ - \ filter messages by\n limit: Maximum number of messages to return (default:\ - \ 50)\n cursor: Opaque cursor string for pagination. Pass the `next_cursor`\ - \ from\n a previous response to get the next page.\n direction:\ - \ Pagination direction - \"older\" to get older messages (default),\n \ - \ \"newer\" to get newer messages.\n\nReturns:\n PaginatedMessagesResponse\ - \ with:\n - data: List of messages (newest first when direction=\"older\"\ - )\n - next_cursor: Cursor for fetching the next page (null if no more pages)\n\ - \ - has_more: Whether there are more messages to fetch\n\nExample:\n \ - \ First request: GET /messages/paginated?task_id=xxx&limit=50\n Next page:\ - \ GET /messages/paginated?task_id=xxx&limit=50&cursor=" - operationId: list_messages_paginated_messages_paginated_get - parameters: - - name: limit - in: query - required: false - schema: - type: integer - default: 50 - title: Limit - - name: cursor - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Cursor - - name: direction - in: query - required: false - schema: - enum: - - older - - newer - type: string - default: older - title: Direction - - name: filters - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: "JSON-encoded array of TaskMessageEntityFilter objects.\n\n\ - Schema: {\n \"$defs\": {\n \"DataContentEntityOptional\": {\n \ - \ \"properties\": {\n \"type\": {\n \"anyOf\": [\n \ - \ {\n \"const\": \"data\",\n \"type\"\ - : \"string\"\n },\n {\n \"type\": \"\ - null\"\n }\n ],\n \"default\": null,\n \ - \ \"description\": \"The type of the message, in this case `data`.\"\ - ,\n \"title\": \"Type\"\n },\n \"author\": {\n\ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The role of the messages author, in this case `system`,\ - \ `user`, `assistant`, or `tool`.\"\n },\n \"style\": {\n\ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The style of the message. This is used by the client\ - \ to determine how to display the message.\"\n },\n \"data\"\ - : {\n \"anyOf\": [\n {\n \"additionalProperties\"\ - : true,\n \"type\": \"object\"\n },\n \ - \ {\n \"type\": \"null\"\n }\n ],\n\ - \ \"default\": null,\n \"description\": \"The contents\ - \ of the data message.\",\n \"title\": \"Data\"\n }\n\ - \ },\n \"title\": \"DataContentEntityOptional\",\n \"type\"\ - : \"object\"\n },\n \"FileAttachmentEntity\": {\n \"description\"\ - : \"Represents a file attachment in messages.\",\n \"properties\"\ - : {\n \"file_id\": {\n \"description\": \"The unique ID\ - \ of the attached file\",\n \"title\": \"File Id\",\n \ - \ \"type\": \"string\"\n },\n \"name\": {\n \"\ - description\": \"The name of the file\",\n \"title\": \"Name\"\ - ,\n \"type\": \"string\"\n },\n \"size\": {\n \ - \ \"description\": \"The size of the file in bytes\",\n \ - \ \"title\": \"Size\",\n \"type\": \"integer\"\n },\n\ - \ \"type\": {\n \"description\": \"The MIME type or content\ - \ type of the file\",\n \"title\": \"Type\",\n \"type\"\ - : \"string\"\n }\n },\n \"required\": [\n \"file_id\"\ - ,\n \"name\",\n \"size\",\n \"type\"\n ],\n\ - \ \"title\": \"FileAttachmentEntity\",\n \"type\": \"object\"\ - \n },\n \"MessageAuthor\": {\n \"enum\": [\n \"user\"\ - ,\n \"agent\"\n ],\n \"title\": \"MessageAuthor\",\n\ - \ \"type\": \"string\"\n },\n \"MessageStyle\": {\n \"\ - enum\": [\n \"static\",\n \"active\"\n ],\n \"\ - title\": \"MessageStyle\",\n \"type\": \"string\"\n },\n \"\ - ReasoningContentEntityOptional\": {\n \"properties\": {\n \ - \ \"type\": {\n \"anyOf\": [\n {\n \"\ - const\": \"reasoning\",\n \"type\": \"string\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The type of the message, in this case `reasoning`.\",\n \"\ - title\": \"Type\"\n },\n \"author\": {\n \"anyOf\"\ - : [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\n\ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The role of the messages author, in this case `system`,\ - \ `user`, `assistant`, or `tool`.\"\n },\n \"style\": {\n\ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The style of the message. This is used by the client\ - \ to determine how to display the message.\"\n },\n \"summary\"\ - : {\n \"anyOf\": [\n {\n \"items\": {\n\ - \ \"type\": \"string\"\n },\n \ - \ \"type\": \"array\"\n },\n {\n \"\ - type\": \"null\"\n }\n ],\n \"default\":\ - \ null,\n \"description\": \"A list of short reasoning summaries\"\ - ,\n \"title\": \"Summary\"\n },\n \"content\":\ - \ {\n \"anyOf\": [\n {\n \"items\": {\n\ - \ \"type\": \"string\"\n },\n \ - \ \"type\": \"array\"\n },\n {\n \"\ - type\": \"null\"\n }\n ],\n \"default\":\ - \ null,\n \"description\": \"The reasoning content or chain-of-thought\ - \ text\",\n \"title\": \"Content\"\n }\n },\n \ - \ \"title\": \"ReasoningContentEntityOptional\",\n \"type\": \"\ - object\"\n },\n \"TextContentEntityOptional\": {\n \"properties\"\ - : {\n \"type\": {\n \"anyOf\": [\n {\n \ - \ \"const\": \"text\",\n \"type\": \"string\"\n\ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The type of the message, in this case `text`.\",\n \ - \ \"title\": \"Type\"\n },\n \"author\": {\n \ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The role of the messages author, in this case `system`,\ - \ `user`, `assistant`, or `tool`.\"\n },\n \"style\": {\n\ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The style of the message. This is used by the client\ - \ to determine how to display the message.\"\n },\n \"format\"\ - : {\n \"anyOf\": [\n {\n \"$ref\": \"\ - #/$defs/TextFormat\"\n },\n {\n \"\ - type\": \"null\"\n }\n ],\n \"default\":\ - \ null,\n \"description\": \"The format of the message. This\ - \ is used by the client to determine how to display the message.\"\n \ - \ },\n \"content\": {\n \"anyOf\": [\n \ - \ {\n \"type\": \"string\"\n },\n \ - \ {\n \"type\": \"null\"\n }\n ],\n\ - \ \"default\": null,\n \"description\": \"The contents\ - \ of the text message.\",\n \"title\": \"Content\"\n },\n\ - \ \"attachments\": {\n \"anyOf\": [\n {\n \ - \ \"items\": {\n \"$ref\": \"#/$defs/FileAttachmentEntity\"\ - \n },\n \"type\": \"array\"\n },\n\ - \ {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"\ - Optional list of file attachments with structured metadata.\",\n \ - \ \"title\": \"Attachments\"\n }\n },\n \"title\"\ - : \"TextContentEntityOptional\",\n \"type\": \"object\"\n },\n\ - \ \"TextFormat\": {\n \"enum\": [\n \"markdown\",\n \ - \ \"plain\",\n \"code\"\n ],\n \"title\": \"TextFormat\"\ - ,\n \"type\": \"string\"\n },\n \"ToolRequestContentEntityOptional\"\ - : {\n \"properties\": {\n \"type\": {\n \"anyOf\"\ - : [\n {\n \"const\": \"tool_request\",\n \ - \ \"type\": \"string\"\n },\n {\n \ - \ \"type\": \"null\"\n }\n ],\n \"\ - default\": null,\n \"description\": \"The type of the message,\ - \ in this case `tool_request`.\",\n \"title\": \"Type\"\n \ - \ },\n \"author\": {\n \"anyOf\": [\n {\n\ - \ \"$ref\": \"#/$defs/MessageAuthor\"\n },\n \ - \ {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"\ - The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\"\ - : [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\n\ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The style of the message. This is used by the client\ - \ to determine how to display the message.\"\n },\n \"tool_call_id\"\ - : {\n \"anyOf\": [\n {\n \"type\": \"\ - string\"\n },\n {\n \"type\": \"null\"\ - \n }\n ],\n \"default\": null,\n \ - \ \"description\": \"The ID of the tool call that is being requested.\"\ - ,\n \"title\": \"Tool Call Id\"\n },\n \"name\"\ - : {\n \"anyOf\": [\n {\n \"type\": \"\ - string\"\n },\n {\n \"type\": \"null\"\ - \n }\n ],\n \"default\": null,\n \ - \ \"description\": \"The name of the tool that is being requested.\"\ - ,\n \"title\": \"Name\"\n },\n \"arguments\": {\n\ - \ \"anyOf\": [\n {\n \"additionalProperties\"\ - : true,\n \"type\": \"object\"\n },\n \ - \ {\n \"type\": \"null\"\n }\n ],\n\ - \ \"default\": null,\n \"description\": \"The arguments\ - \ to the tool.\",\n \"title\": \"Arguments\"\n }\n \ - \ },\n \"title\": \"ToolRequestContentEntityOptional\",\n \ - \ \"type\": \"object\"\n },\n \"ToolResponseContentEntityOptional\"\ - : {\n \"properties\": {\n \"type\": {\n \"anyOf\"\ - : [\n {\n \"const\": \"tool_response\",\n \ - \ \"type\": \"string\"\n },\n {\n \ - \ \"type\": \"null\"\n }\n ],\n \"\ - default\": null,\n \"description\": \"The type of the message,\ - \ in this case `tool_response`.\",\n \"title\": \"Type\"\n \ - \ },\n \"author\": {\n \"anyOf\": [\n \ - \ {\n \"$ref\": \"#/$defs/MessageAuthor\"\n },\n\ - \ {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"\ - The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\"\ - : [\n {\n \"$ref\": \"#/$defs/MessageStyle\"\n\ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"\ - description\": \"The style of the message. This is used by the client\ - \ to determine how to display the message.\"\n },\n \"tool_call_id\"\ - : {\n \"anyOf\": [\n {\n \"type\": \"\ - string\"\n },\n {\n \"type\": \"null\"\ - \n }\n ],\n \"default\": null,\n \ - \ \"description\": \"The ID of the tool call that is being responded\ - \ to.\",\n \"title\": \"Tool Call Id\"\n },\n \"\ - name\": {\n \"anyOf\": [\n {\n \"type\"\ - : \"string\"\n },\n {\n \"type\": \"\ - null\"\n }\n ],\n \"default\": null,\n \ - \ \"description\": \"The name of the tool that is being responded\ - \ to.\",\n \"title\": \"Name\"\n },\n \"content\"\ - : {\n \"anyOf\": [\n {},\n {\n \ - \ \"type\": \"null\"\n }\n ],\n \"default\"\ - : null,\n \"description\": \"The result of the tool.\",\n \ - \ \"title\": \"Content\"\n }\n },\n \"title\":\ - \ \"ToolResponseContentEntityOptional\",\n \"type\": \"object\"\n\ - \ }\n },\n \"description\": \"Filter model for TaskMessage - all\ - \ fields optional for flexible filtering.\\n\\nThe `exclude` field determines\ - \ whether this filter is inclusionary or exclusionary.\\nWhen multiple\ - \ filters are provided:\\n- Inclusionary filters (exclude=False) are OR'd\ - \ together\\n- Exclusionary filters (exclude=True) are OR'd together and\ - \ negated with $nor\\n- The two groups are AND'd: (include1 OR include2)\ - \ AND NOT (exclude1 OR exclude2)\",\n \"properties\": {\n \"content\"\ - : {\n \"anyOf\": [\n {\n \"$ref\": \"#/$defs/ToolRequestContentEntityOptional\"\ - \n },\n {\n \"$ref\": \"#/$defs/DataContentEntityOptional\"\ - \n },\n {\n \"$ref\": \"#/$defs/TextContentEntityOptional\"\ - \n },\n {\n \"$ref\": \"#/$defs/ToolResponseContentEntityOptional\"\ - \n },\n {\n \"$ref\": \"#/$defs/ReasoningContentEntityOptional\"\ - \n },\n {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"Filter by message\ - \ content\",\n \"title\": \"Content\"\n },\n \"streaming_status\"\ - : {\n \"anyOf\": [\n {\n \"enum\": [\n \ - \ \"IN_PROGRESS\",\n \"DONE\"\n ],\n \"\ - type\": \"string\"\n },\n {\n \"type\": \"null\"\ - \n }\n ],\n \"default\": null,\n \"description\"\ - : \"Filter by streaming status\",\n \"title\": \"Streaming Status\"\ - \n },\n \"exclude\": {\n \"default\": false,\n \"description\"\ - : \"If true, this filter excludes matching messages\",\n \"title\"\ - : \"Exclude\",\n \"type\": \"boolean\"\n }\n },\n \"title\"\ - : \"TaskMessageEntityFilter\",\n \"type\": \"object\"\n}\n\nEach filter\ - \ can include:\n- `content`: Filter by message content (type, author,\ - \ data fields)\n- `streaming_status`: Filter by status (\"IN_PROGRESS\"\ - \ or \"DONE\")\n- `exclude`: If true, excludes matching messages (default:\ - \ false)\n\nMultiple filters are combined: inclusionary filters (exclude=false)\ - \ are OR'd together,\nexclusionary filters (exclude=true) are OR'd and\ - \ negated, then both groups are AND'd.\n" - title: Filters - description: "JSON-encoded array of TaskMessageEntityFilter objects.\n\nSchema:\ - \ {\n \"$defs\": {\n \"DataContentEntityOptional\": {\n \"properties\"\ - : {\n \"type\": {\n \"anyOf\": [\n {\n \ - \ \"const\": \"data\",\n \"type\": \"string\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The type of the message, in this case `data`.\",\n \"title\"\ - : \"Type\"\n },\n \"author\": {\n \"anyOf\": [\n\ - \ {\n \"$ref\": \"#/$defs/MessageAuthor\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ - \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The style of the message. This is used by the client to determine how\ - \ to display the message.\"\n },\n \"data\": {\n \ - \ \"anyOf\": [\n {\n \"additionalProperties\": true,\n\ - \ \"type\": \"object\"\n },\n {\n \ - \ \"type\": \"null\"\n }\n ],\n \"\ - default\": null,\n \"description\": \"The contents of the data\ - \ message.\",\n \"title\": \"Data\"\n }\n },\n \ - \ \"title\": \"DataContentEntityOptional\",\n \"type\": \"object\"\ - \n },\n \"FileAttachmentEntity\": {\n \"description\": \"Represents\ - \ a file attachment in messages.\",\n \"properties\": {\n \"\ - file_id\": {\n \"description\": \"The unique ID of the attached\ - \ file\",\n \"title\": \"File Id\",\n \"type\": \"string\"\ - \n },\n \"name\": {\n \"description\": \"The name\ - \ of the file\",\n \"title\": \"Name\",\n \"type\": \"\ - string\"\n },\n \"size\": {\n \"description\": \"\ - The size of the file in bytes\",\n \"title\": \"Size\",\n \ - \ \"type\": \"integer\"\n },\n \"type\": {\n \ - \ \"description\": \"The MIME type or content type of the file\",\n \ - \ \"title\": \"Type\",\n \"type\": \"string\"\n }\n\ - \ },\n \"required\": [\n \"file_id\",\n \"name\"\ - ,\n \"size\",\n \"type\"\n ],\n \"title\": \"FileAttachmentEntity\"\ - ,\n \"type\": \"object\"\n },\n \"MessageAuthor\": {\n \"\ - enum\": [\n \"user\",\n \"agent\"\n ],\n \"title\"\ - : \"MessageAuthor\",\n \"type\": \"string\"\n },\n \"MessageStyle\"\ - : {\n \"enum\": [\n \"static\",\n \"active\"\n ],\n\ - \ \"title\": \"MessageStyle\",\n \"type\": \"string\"\n },\n\ - \ \"ReasoningContentEntityOptional\": {\n \"properties\": {\n \ - \ \"type\": {\n \"anyOf\": [\n {\n \ - \ \"const\": \"reasoning\",\n \"type\": \"string\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The type of the message, in this case `reasoning`.\",\n \"\ - title\": \"Type\"\n },\n \"author\": {\n \"anyOf\"\ - : [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\n\ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ - \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The style of the message. This is used by the client to determine how\ - \ to display the message.\"\n },\n \"summary\": {\n \ - \ \"anyOf\": [\n {\n \"items\": {\n \ - \ \"type\": \"string\"\n },\n \"type\":\ - \ \"array\"\n },\n {\n \"type\": \"null\"\ - \n }\n ],\n \"default\": null,\n \"\ - description\": \"A list of short reasoning summaries\",\n \"title\"\ - : \"Summary\"\n },\n \"content\": {\n \"anyOf\":\ - \ [\n {\n \"items\": {\n \"type\"\ - : \"string\"\n },\n \"type\": \"array\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The reasoning content or chain-of-thought text\",\n \"title\"\ - : \"Content\"\n }\n },\n \"title\": \"ReasoningContentEntityOptional\"\ - ,\n \"type\": \"object\"\n },\n \"TextContentEntityOptional\"\ - : {\n \"properties\": {\n \"type\": {\n \"anyOf\":\ - \ [\n {\n \"const\": \"text\",\n \"\ - type\": \"string\"\n },\n {\n \"type\"\ - : \"null\"\n }\n ],\n \"default\": null,\n\ - \ \"description\": \"The type of the message, in this case `text`.\"\ - ,\n \"title\": \"Type\"\n },\n \"author\": {\n \ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ - \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The style of the message. This is used by the client to determine how\ - \ to display the message.\"\n },\n \"format\": {\n \ - \ \"anyOf\": [\n {\n \"$ref\": \"#/$defs/TextFormat\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The format of the message. This is used by the client to determine how\ - \ to display the message.\"\n },\n \"content\": {\n \ - \ \"anyOf\": [\n {\n \"type\": \"string\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The contents of the text message.\",\n \"title\": \"Content\"\ - \n },\n \"attachments\": {\n \"anyOf\": [\n \ - \ {\n \"items\": {\n \"$ref\": \"#/$defs/FileAttachmentEntity\"\ - \n },\n \"type\": \"array\"\n },\n\ - \ {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"Optional\ - \ list of file attachments with structured metadata.\",\n \"title\"\ - : \"Attachments\"\n }\n },\n \"title\": \"TextContentEntityOptional\"\ - ,\n \"type\": \"object\"\n },\n \"TextFormat\": {\n \"enum\"\ - : [\n \"markdown\",\n \"plain\",\n \"code\"\n \ - \ ],\n \"title\": \"TextFormat\",\n \"type\": \"string\"\n \ - \ },\n \"ToolRequestContentEntityOptional\": {\n \"properties\"\ - : {\n \"type\": {\n \"anyOf\": [\n {\n \ - \ \"const\": \"tool_request\",\n \"type\": \"string\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The type of the message, in this case `tool_request`.\",\n \ - \ \"title\": \"Type\"\n },\n \"author\": {\n \"anyOf\"\ - : [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\n\ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ - \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The style of the message. This is used by the client to determine how\ - \ to display the message.\"\n },\n \"tool_call_id\": {\n \ - \ \"anyOf\": [\n {\n \"type\": \"string\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The ID of the tool call that is being requested.\",\n \"title\"\ - : \"Tool Call Id\"\n },\n \"name\": {\n \"anyOf\"\ - : [\n {\n \"type\": \"string\"\n },\n\ - \ {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"The\ - \ name of the tool that is being requested.\",\n \"title\": \"\ - Name\"\n },\n \"arguments\": {\n \"anyOf\": [\n \ - \ {\n \"additionalProperties\": true,\n \ - \ \"type\": \"object\"\n },\n {\n \ - \ \"type\": \"null\"\n }\n ],\n \"default\"\ - : null,\n \"description\": \"The arguments to the tool.\",\n \ - \ \"title\": \"Arguments\"\n }\n },\n \"title\"\ - : \"ToolRequestContentEntityOptional\",\n \"type\": \"object\"\n \ - \ },\n \"ToolResponseContentEntityOptional\": {\n \"properties\"\ - : {\n \"type\": {\n \"anyOf\": [\n {\n \ - \ \"const\": \"tool_response\",\n \"type\": \"string\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The type of the message, in this case `tool_response`.\",\n \ - \ \"title\": \"Type\"\n },\n \"author\": {\n \"\ - anyOf\": [\n {\n \"$ref\": \"#/$defs/MessageAuthor\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The role of the messages author, in this case `system`, `user`, `assistant`,\ - \ or `tool`.\"\n },\n \"style\": {\n \"anyOf\": [\n\ - \ {\n \"$ref\": \"#/$defs/MessageStyle\"\n \ - \ },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The style of the message. This is used by the client to determine how\ - \ to display the message.\"\n },\n \"tool_call_id\": {\n \ - \ \"anyOf\": [\n {\n \"type\": \"string\"\ - \n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The ID of the tool call that is being responded to.\",\n \"\ - title\": \"Tool Call Id\"\n },\n \"name\": {\n \"\ - anyOf\": [\n {\n \"type\": \"string\"\n \ - \ },\n {\n \"type\": \"null\"\n }\n\ - \ ],\n \"default\": null,\n \"description\":\ - \ \"The name of the tool that is being responded to.\",\n \"title\"\ - : \"Name\"\n },\n \"content\": {\n \"anyOf\": [\n\ - \ {},\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\"\ - : \"The result of the tool.\",\n \"title\": \"Content\"\n \ - \ }\n },\n \"title\": \"ToolResponseContentEntityOptional\"\ - ,\n \"type\": \"object\"\n }\n },\n \"description\": \"Filter\ - \ model for TaskMessage - all fields optional for flexible filtering.\\\ - n\\nThe `exclude` field determines whether this filter is inclusionary or\ - \ exclusionary.\\nWhen multiple filters are provided:\\n- Inclusionary filters\ - \ (exclude=False) are OR'd together\\n- Exclusionary filters (exclude=True)\ - \ are OR'd together and negated with $nor\\n- The two groups are AND'd:\ - \ (include1 OR include2) AND NOT (exclude1 OR exclude2)\",\n \"properties\"\ - : {\n \"content\": {\n \"anyOf\": [\n {\n \"$ref\"\ - : \"#/$defs/ToolRequestContentEntityOptional\"\n },\n {\n\ - \ \"$ref\": \"#/$defs/DataContentEntityOptional\"\n },\n\ - \ {\n \"$ref\": \"#/$defs/TextContentEntityOptional\"\n\ - \ },\n {\n \"$ref\": \"#/$defs/ToolResponseContentEntityOptional\"\ - \n },\n {\n \"$ref\": \"#/$defs/ReasoningContentEntityOptional\"\ - \n },\n {\n \"type\": \"null\"\n }\n \ - \ ],\n \"default\": null,\n \"description\": \"Filter by message\ - \ content\",\n \"title\": \"Content\"\n },\n \"streaming_status\"\ - : {\n \"anyOf\": [\n {\n \"enum\": [\n \"\ - IN_PROGRESS\",\n \"DONE\"\n ],\n \"type\":\ - \ \"string\"\n },\n {\n \"type\": \"null\"\n \ - \ }\n ],\n \"default\": null,\n \"description\": \"Filter\ - \ by streaming status\",\n \"title\": \"Streaming Status\"\n },\n\ - \ \"exclude\": {\n \"default\": false,\n \"description\": \"\ - If true, this filter excludes matching messages\",\n \"title\": \"\ - Exclude\",\n \"type\": \"boolean\"\n }\n },\n \"title\": \"TaskMessageEntityFilter\"\ - ,\n \"type\": \"object\"\n}\n\nEach filter can include:\n- `content`: Filter\ - \ by message content (type, author, data fields)\n- `streaming_status`:\ - \ Filter by status (\"IN_PROGRESS\" or \"DONE\")\n- `exclude`: If true,\ - \ excludes matching messages (default: false)\n\nMultiple filters are combined:\ - \ inclusionary filters (exclude=false) are OR'd together,\nexclusionary\ - \ filters (exclude=true) are OR'd and negated, then both groups are AND'd.\n" - examples: - single_filter: - summary: Filter by content type - value: '{"content": {"type": "text"}}' - multiple_types: - summary: Filter multiple content types (OR) - value: '[{"content": {"type": "text"}}, {"content": {"type": "data"}}]' - with_exclusion: - summary: Include data messages, exclude specific data types - value: '[{"content": {"type": "data"}}, {"content": {"data": {"type": - "error_report"}}, "exclude": true}]' - nested_data: - summary: Filter by nested data field - value: '{"content": {"data": {"type": "report_status_update"}}}' - - name: task_id - in: query - required: true - schema: - type: string - description: The task ID - title: Task Id - description: The task ID - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/PaginatedMessagesResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /spans: - post: - tags: - - Spans - summary: Create Span - description: Create a new span with the provided parameters - operationId: create_span_spans_post - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateSpanRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Span' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - get: - tags: - - Spans - summary: List Spans - description: List spans, optionally filtered by trace_id and/or task_id - operationId: list_spans_spans_get - parameters: - - name: trace_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Trace Id - - name: task_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Task Id - - name: limit - in: query - required: false - schema: - type: integer - maximum: 1000 - minimum: 1 - default: 50 - title: Limit - - name: page_number - in: query - required: false - schema: - type: integer - minimum: 1 - default: 1 - title: Page Number - - name: order_by - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Order By - - name: order_direction - in: query - required: false - schema: - type: string - default: desc - title: Order Direction - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/Span' - title: Response List Spans Spans Get - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /spans/{span_id}: - patch: - tags: - - Spans - summary: Partial Update Span - description: Update a span with the provided output data and mark it as complete - operationId: partial_update_span_spans__span_id__patch - parameters: - - name: span_id - in: path - required: true - schema: - type: string - title: Span Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateSpanRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Span' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - get: - tags: - - Spans - summary: Get Span - description: Get a span by ID - operationId: get_span_spans__span_id__get - parameters: - - name: span_id - in: path - required: true - schema: - type: string - title: Span Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Span' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /states: - post: - tags: - - States - summary: Create Task State - operationId: create_task_state_states_post - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateStateRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/State' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - get: - tags: - - States - summary: List States - description: List all states, optionally filtered by query parameters. - operationId: filter_states_states_get - parameters: - - name: task_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: Task ID - title: Task Id - description: Task ID - - name: agent_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: Agent ID - title: Agent Id - description: Agent ID - - name: limit - in: query - required: false - schema: - type: integer - minimum: 1 - description: Limit - default: 50 - title: Limit - description: Limit - - name: page_number - in: query - required: false - schema: - type: integer - minimum: 1 - description: Page number - default: 1 - title: Page Number - description: Page number - - name: order_by - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: Field to order by - title: Order By - description: Field to order by - - name: order_direction - in: query - required: false - schema: - type: string - description: Order direction (asc or desc) - default: desc - title: Order Direction - description: Order direction (asc or desc) - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/State' - title: Response Filter States States Get - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /states/{state_id}: - get: - tags: - - States - summary: Get State by State ID - description: Get a state by its unique state ID. - operationId: get_state_states__state_id__get - parameters: - - name: state_id - in: path - required: true - schema: - type: string - title: State Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/State' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - put: - tags: - - States - summary: Update Task State - operationId: update_task_state_states__state_id__put - parameters: - - name: state_id - in: path - required: true - schema: - type: string - title: State Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateStateRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/State' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - delete: - tags: - - States - summary: Delete Task State - operationId: delete_task_state_states__state_id__delete - parameters: - - name: state_id - in: path - required: true - schema: - type: string - title: State Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/State' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /events/{event_id}: - get: - tags: - - Events - summary: Get Event - operationId: get_event_events__event_id__get - parameters: - - name: event_id - in: path - required: true - schema: - type: string - title: Event Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Event' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /events: - get: - tags: - - Events - summary: List Events - description: 'List events for a specific task and agent. - - - Optionally filter for events after a specific sequence ID. - - Results are ordered by sequence_id.' - operationId: list_events_events_get - parameters: - - name: last_processed_event_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: Optional event ID to get events after this ID - title: Last Processed Event Id - description: Optional event ID to get events after this ID - - name: limit - in: query - required: false - schema: - anyOf: - - type: integer - maximum: 1000 - minimum: 1 - - type: 'null' - description: Optional limit on number of results - title: Limit - description: Optional limit on number of results - - name: task_id - in: query - required: true - schema: - type: string - description: The task ID to filter events by - title: Task Id - description: The task ID to filter events by - - name: agent_id - in: query - required: true - schema: - type: string - description: The agent ID to filter events by - title: Agent Id - description: The agent ID to filter events by - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/Event' - title: Response List Events Events Get - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tracker/{tracker_id}: - get: - tags: - - Agent Task Tracker - summary: Get Agent Task Tracker - description: Get agent task tracker by tracker ID - operationId: get_agent_task_tracker_tracker__tracker_id__get - parameters: - - name: tracker_id - in: path - required: true - schema: - type: string - title: Tracker Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/AgentTaskTracker' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - put: - tags: - - Agent Task Tracker - summary: Update Agent Task Tracker - description: Update agent task tracker by tracker ID - operationId: update_agent_task_tracker_tracker__tracker_id__put - parameters: - - name: tracker_id - in: path - required: true - schema: - type: string - title: Tracker Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateAgentTaskTrackerRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/AgentTaskTracker' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tracker: - get: - tags: - - Agent Task Tracker - summary: List Agent Task Trackers - description: List all agent task trackers, optionally filtered by query parameters. - operationId: filter_agent_task_tracker_tracker_get - parameters: - - name: agent_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: Agent ID - title: Agent Id - description: Agent ID - - name: task_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: Task ID - title: Task Id - description: Task ID - - name: limit - in: query - required: false - schema: - type: integer - minimum: 1 - description: Limit - default: 50 - title: Limit - description: Limit - - name: page_number - in: query - required: false - schema: - type: integer - minimum: 1 - description: Page number - default: 1 - title: Page Number - description: Page number - - name: order_by - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: Field to order by - title: Order By - description: Field to order by - - name: order_direction - in: query - required: false - schema: - type: string - description: Order direction (asc or desc) - default: desc - title: Order Direction - description: Order direction (asc or desc) - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/AgentTaskTracker' - title: Response Filter Agent Task Tracker Tracker Get - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agent_api_keys: - post: - tags: - - Agent APIKeys - summary: Create Api Key - operationId: create_api_key_agent_api_keys_post - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateAPIKeyRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/CreateAPIKeyResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - get: - tags: - - Agent APIKeys - summary: List API keys for an agent ID - description: List API keys for an agent ID. - operationId: list_agent_api_keys_agent_api_keys_get - parameters: - - name: agent_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Agent Id - - name: agent_name - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Agent Name - - name: limit - in: query - required: false - schema: - type: integer - maximum: 1000 - minimum: 1 - default: 50 - title: Limit - - name: page_number - in: query - required: false - schema: - type: integer - minimum: 1 - default: 1 - title: Page Number - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/AgentAPIKey' - title: Response List Agent Api Keys Agent Api Keys Get - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agent_api_keys/name/{name}: - get: - tags: - - Agent APIKeys - summary: Return named API key for the agent ID - description: Return named API key for the agent ID. - operationId: get_agent_api_key_by_name_agent_api_keys_name__name__get - parameters: - - name: name - in: path - required: true - schema: - type: string - title: Name - - name: agent_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Agent Id - - name: agent_name - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Agent Name - - name: api_key_type - in: query - required: false - schema: - $ref: '#/components/schemas/AgentAPIKeyType' - default: external - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/AgentAPIKey' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agent_api_keys/{id}: - get: - tags: - - Agent APIKeys - summary: Return the API key by ID - description: Return API key by ID. - operationId: get_agent_api_key_agent_api_keys__id__get - parameters: - - name: id - in: path - required: true - schema: - type: string - title: Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/AgentAPIKey' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - delete: - tags: - - Agent APIKeys - summary: Delete API key by ID - description: Delete API key by ID. - operationId: delete_agent_api_key_agent_api_keys__id__delete - parameters: - - name: id - in: path - required: true - schema: - type: string - title: Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: string - title: Response Delete Agent Api Key Agent Api Keys Id Delete - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agent_api_keys/name/{api_key_name}: - delete: - tags: - - Agent APIKeys - summary: Delete API key by name - description: Delete API key by name. - operationId: delete_agent_api_key_by_name_agent_api_keys_name__api_key_name__delete - parameters: - - name: api_key_name - in: path - required: true - schema: - type: string - title: Api Key Name - - name: agent_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Agent Id - - name: agent_name - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Agent Name - - name: api_key_type - in: query - required: false - schema: - $ref: '#/components/schemas/AgentAPIKeyType' - default: external - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: string - title: Response Delete Agent Api Key By Name Agent Api Keys Name Api - Key Name Delete - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /deployment-history/{deployment_id}: - get: - tags: - - Deployment History - summary: Get Deployment by ID - description: Get a deployment record by its unique ID. - operationId: get_deployment_by_id_deployment_history__deployment_id__get - parameters: - - name: deployment_id - in: path - required: true - schema: - type: string - title: Deployment Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/DeploymentHistory' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /deployment-history: - get: - tags: - - Deployment History - summary: List Deployments for an agent - description: List deployment history for an agent. - operationId: list_deployments_deployment_history_get - parameters: - - name: agent_id - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Agent Id - - name: agent_name - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Agent Name - - name: limit - in: query - required: false - schema: - type: integer - default: 50 - title: Limit - - name: page_number - in: query - required: false - schema: - type: integer - default: 1 - title: Page Number - - name: order_by - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - title: Order By - - name: order_direction - in: query - required: false - schema: - type: string - default: desc - title: Order Direction - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/DeploymentHistory' - title: Response List Deployments Deployment History Get - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/{agent_id}/deployments: - post: - tags: - - Deployments - summary: Create Deployment - description: Create a new deployment record in PENDING status. - operationId: create_deployment_agents__agent_id__deployments_post - parameters: - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateDeploymentRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Deployment' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - get: - tags: - - Deployments - summary: List Deployments - description: List deployments for an agent, newest first. - operationId: list_deployments_agents__agent_id__deployments_get - parameters: - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - - name: limit - in: query - required: false - schema: - type: integer - minimum: 1 - description: Limit - default: 50 - title: Limit - description: Limit - - name: page_number - in: query - required: false - schema: - type: integer - minimum: 1 - description: Page number - default: 1 - title: Page Number - description: Page number - - name: order_by - in: query - required: false - schema: - anyOf: - - type: string - - type: 'null' - description: Field to order by - title: Order By - description: Field to order by - - name: order_direction - in: query - required: false - schema: - type: string - description: Order direction (asc or desc) - default: desc - title: Order Direction - description: Order direction (asc or desc) - responses: - '200': - description: Successful Response - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/Deployment' - title: Response List Deployments Agents Agent Id Deployments Get - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/{agent_id}/deployments/{deployment_id}: - get: - tags: - - Deployments - summary: Get Deployment - description: Get a specific deployment by ID. - operationId: get_deployment_agents__agent_id__deployments__deployment_id__get - parameters: - - name: deployment_id - in: path - required: true - schema: - type: string - title: Deployment Id - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Deployment' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - delete: - tags: - - Deployments - summary: Delete Deployment - description: Delete a non-production deployment. - operationId: delete_deployment_agents__agent_id__deployments__deployment_id__delete - parameters: - - name: deployment_id - in: path - required: true - schema: - type: string - title: Deployment Id - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/{agent_id}/deployments/{deployment_id}/promote: - post: - tags: - - Deployments - summary: Promote Deployment - description: Promote a deployment to production with atomic cutover. - operationId: promote_deployment_agents__agent_id__deployments__deployment_id__promote_post - parameters: - - name: deployment_id - in: path - required: true - schema: - type: string - title: Deployment Id - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Deployment' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/{agent_id}/deployments/{deployment_id}/rpc: - post: - tags: - - Deployments - summary: Preview RPC - description: Send an RPC request to a specific deployment (for preview testing). - operationId: handle_deployment_rpc_agents__agent_id__deployments__deployment_id__rpc_post - parameters: - - name: deployment_id - in: path - required: true - schema: - type: string - title: Deployment Id - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/AgentRPCRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/AgentRPCResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/{agent_id}/schedules: - post: - tags: - - Schedules - summary: Create Schedule - description: Create a new schedule for recurring workflow execution for an agent. - operationId: create_schedule_agents__agent_id__schedules_post - parameters: - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateScheduleRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/ScheduleResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - get: - tags: - - Schedules - summary: List Agent Schedules - description: List all schedules for an agent. - operationId: list_schedules_agents__agent_id__schedules_get - parameters: - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - - name: page_size - in: query - required: false - schema: - type: integer - maximum: 1000 - minimum: 1 - default: 100 - title: Page Size - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/ScheduleListResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/{agent_id}/schedules/{schedule_name}: - get: - tags: - - Schedules - summary: Get Schedule - description: Get details of a schedule by its name. - operationId: get_schedule_agents__agent_id__schedules__schedule_name__get - parameters: - - name: schedule_name - in: path - required: true - schema: - type: string - title: Schedule Name - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/ScheduleResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - delete: - tags: - - Schedules - summary: Delete Schedule - description: Delete a schedule permanently. - operationId: delete_schedule_agents__agent_id__schedules__schedule_name__delete - parameters: - - name: schedule_name - in: path - required: true - schema: - type: string - title: Schedule Name - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/{agent_id}/schedules/{schedule_name}/pause: - post: - tags: - - Schedules - summary: Pause Schedule - description: Pause a schedule to stop it from executing. - operationId: pause_schedule_agents__agent_id__schedules__schedule_name__pause_post - parameters: - - name: schedule_name - in: path - required: true - schema: - type: string - title: Schedule Name - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - requestBody: - content: - application/json: - schema: - anyOf: - - $ref: '#/components/schemas/PauseScheduleRequest' - - type: 'null' - title: Request - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/ScheduleResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/{agent_id}/schedules/{schedule_name}/unpause: - post: - tags: - - Schedules - summary: Unpause Schedule - description: Unpause/resume a schedule to allow it to execute again. - operationId: unpause_schedule_agents__agent_id__schedules__schedule_name__unpause_post - parameters: - - name: schedule_name - in: path - required: true - schema: - type: string - title: Schedule Name - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - requestBody: - content: - application/json: - schema: - anyOf: - - $ref: '#/components/schemas/UnpauseScheduleRequest' - - type: 'null' - title: Request - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/ScheduleResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /agents/{agent_id}/schedules/{schedule_name}/trigger: - post: - tags: - - Schedules - summary: Trigger Schedule - description: Trigger a schedule to run immediately, regardless of its regular - schedule. - operationId: trigger_schedule_agents__agent_id__schedules__schedule_name__trigger_post - parameters: - - name: schedule_name - in: path - required: true - schema: - type: string - title: Schedule Name - - name: agent_id - in: path - required: true - schema: - type: string - title: Agent Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/ScheduleResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /checkpoints/get-tuple: - post: - tags: - - Checkpoints - summary: Get Checkpoint Tuple - operationId: get_checkpoint_tuple_checkpoints_get_tuple_post - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/GetCheckpointTupleRequest' - required: true - responses: - '200': - description: Successful Response - content: - application/json: - schema: - anyOf: - - $ref: '#/components/schemas/CheckpointTupleResponse' - - type: 'null' - title: Response Get Checkpoint Tuple Checkpoints Get Tuple Post - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /checkpoints/put: - post: - tags: - - Checkpoints - summary: Put Checkpoint - operationId: put_checkpoint_checkpoints_put_post - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PutCheckpointRequest' - required: true - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/PutCheckpointResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /checkpoints/put-writes: - post: - tags: - - Checkpoints - summary: Put Writes - operationId: put_writes_checkpoints_put_writes_post - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PutWritesRequest' - required: true - responses: - '204': - description: Successful Response - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /checkpoints/list: - post: - tags: - - Checkpoints - summary: List Checkpoints - operationId: list_checkpoints_checkpoints_list_post - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/ListCheckpointsRequest' - required: true - responses: - '200': - description: Successful Response - content: - application/json: - schema: - items: - $ref: '#/components/schemas/CheckpointListItem' - type: array - title: Response List Checkpoints Checkpoints List Post - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /checkpoints/delete-thread: - post: - tags: - - Checkpoints - summary: Delete Thread - operationId: delete_thread_checkpoints_delete_thread_post - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteThreadRequest' - required: true - responses: - '204': - description: Successful Response - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/{task_id}/export: - get: - tags: - - task-retention - summary: Export Task - description: 'Build a self-contained snapshot of a task''s content surfaces. - - - Returns the exact payload format that POST /rehydrate accepts, so - - export โ†’ clean โ†’ rehydrate is a round-trip-equivalent operation.' - operationId: export_task_tasks__task_id__export_get - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/ExportTaskResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - post: - tags: - - task-retention - summary: Export Task To Url - description: 'Build the task snapshot and PUT it to a caller-supplied presigned - URL. - - - Use this when the snapshot is too large for a JSON response body (long - - conversations, deep reasoning content, many attachments). The upload URL - - must be https and resolve to a public address โ€” see SSRF guard.' - operationId: export_task_to_url_tasks__task_id__export_post - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ExportTaskToUrlRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/ExportTaskToUrlResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/{task_id}/clean: - post: - tags: - - task-retention - summary: Clean Task - description: 'Delete content-bearing rows for a stale task. - - - Refuses on active tasks, in-flight workflows, or unprocessed events - - regardless of `force`. The `force=true` flag only bypasses the - - idle-threshold check.' - operationId: clean_task_tasks__task_id__clean_post - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CleanTaskRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/CleanTaskResponse' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' - /tasks/{task_id}/rehydrate: - post: - tags: - - task-retention - summary: Rehydrate Task - description: 'Restore content-bearing rows from a snapshot. - - - Two modes: - - - Inline: caller provides messages and task_states in the request body. - - - URL: caller provides snapshot_url; Agentex downloads and parses it. - - - Refuses if the task isn''t currently in a cleaned state, or if any supplied - - message/state ID already exists in Mongo (catches double-rehydrate).' - operationId: rehydrate_task_tasks__task_id__rehydrate_post - parameters: - - name: task_id - in: path - required: true - schema: - type: string - title: Task Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/RehydrateTaskRequest' - responses: - '204': - description: Successful Response - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' -components: - schemas: - ACPType: - type: string - enum: - - sync - - async - - agentic - title: ACPType - Agent: - properties: - id: - type: string - title: Id - description: The unique identifier of the agent. - name: - type: string - title: Name - description: The unique name of the agent. - description: - type: string - title: Description - description: The description of the action. - status: - $ref: '#/components/schemas/AgentStatus' - description: The status of the action, indicating if it's building, ready, - failed, etc. - default: Unknown - acp_type: - $ref: '#/components/schemas/ACPType' - description: The type of the ACP Server (Either sync or async) - status_reason: - anyOf: - - type: string - - type: 'null' - title: Status Reason - description: The reason for the status of the action. - created_at: - type: string - format: date-time - title: Created At - description: The timestamp when the agent was created - updated_at: - type: string - format: date-time - title: Updated At - description: The timestamp when the agent was last updated - registration_metadata: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Registration Metadata - description: The metadata for the agent's registration. - registered_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Registered At - description: The timestamp when the agent was last registered - agent_input_type: - anyOf: - - $ref: '#/components/schemas/AgentInputType' - - type: 'null' - description: The type of input the agent expects. - production_deployment_id: - anyOf: - - type: string - - type: 'null' - title: Production Deployment Id - description: ID of the current production deployment. - type: object - required: - - id - - name - - description - - acp_type - - created_at - - updated_at - title: Agent - AgentAPIKey: - properties: - id: - type: string - title: Id - description: The unique identifier of the agent API key. - agent_id: - type: string - title: Agent Id - description: The UUID of the agent - created_at: - type: string - format: date-time - title: Created At - description: When the agent API key was created - name: - anyOf: - - type: string - - type: 'null' - title: Name - description: The optional name of the agent API key. - api_key_type: - $ref: '#/components/schemas/AgentAPIKeyType' - description: The type of the agent API key (either internal or external) - type: object - required: - - id - - agent_id - - created_at - - name - - api_key_type - title: AgentAPIKey - AgentAPIKeyType: - type: string - enum: - - internal - - external - - github - - slack - title: AgentAPIKeyType - AgentInputType: - type: string - enum: - - text - - json - title: AgentInputType - AgentRPCMethod: - type: string - enum: - - event/send - - task/create - - message/send - - task/cancel - title: AgentRPCMethod - AgentRPCParams: - anyOf: - - $ref: '#/components/schemas/CreateTaskRequest' - - $ref: '#/components/schemas/CancelTaskRequest' - - $ref: '#/components/schemas/SendMessageRequest' - - $ref: '#/components/schemas/SendEventRequest' - title: AgentRPCParams - description: The parameters for the agent RPC request - AgentRPCRequest: - properties: - jsonrpc: - type: string - const: '2.0' - title: Jsonrpc - default: '2.0' - method: - $ref: '#/components/schemas/AgentRPCMethod' - params: - $ref: '#/components/schemas/AgentRPCParams' - id: - anyOf: - - type: integer - - type: string - - type: 'null' - title: Id - type: object - required: - - method - - params - title: AgentRPCRequest - AgentRPCResponse: - properties: - jsonrpc: - type: string - const: '2.0' - title: Jsonrpc - default: '2.0' - result: - $ref: '#/components/schemas/AgentRPCResult' - description: The result of the agent RPC request - error: - anyOf: - - {} - - type: 'null' - title: Error - id: - anyOf: - - type: integer - - type: string - - type: 'null' - title: Id - type: object - required: - - result - title: AgentRPCResponse - AgentRPCResult: - anyOf: - - items: - $ref: '#/components/schemas/TaskMessage' - type: array - - $ref: '#/components/schemas/TaskMessageUpdate' - - $ref: '#/components/schemas/Task' - - $ref: '#/components/schemas/Event' - - type: 'null' - title: AgentRPCResult - AgentStatus: - type: string - enum: - - Ready - - Failed - - Unknown - - Deleted - - Unhealthy - - BuildOnly - title: AgentStatus - AgentTaskTracker: - properties: - id: - type: string - title: Id - description: The UUID of the agent task tracker - agent_id: - type: string - title: Agent Id - description: The UUID of the agent - task_id: - type: string - title: Task Id - description: The UUID of the task - status: - anyOf: - - type: string - - type: 'null' - title: Status - description: Processing status - status_reason: - anyOf: - - type: string - - type: 'null' - title: Status Reason - description: Optional status reason - last_processed_event_id: - anyOf: - - type: string - - type: 'null' - title: Last Processed Event Id - description: The last processed event ID - created_at: - type: string - format: date-time - title: Created At - description: When the agent task tracker was created - updated_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Updated At - description: When the agent task tracker was last updated - type: object - required: - - id - - agent_id - - task_id - - created_at - title: AgentTaskTracker - BatchCreateTaskMessagesRequest: - properties: - task_id: - type: string - title: The unique id of the task to send the messages to - contents: - items: - $ref: '#/components/schemas/TaskMessageContent' - type: array - title: The messages to send to the task. The order of the messages will - be the order they are added to the task. - created_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Optional caller-supplied base creation timestamp for the batch - description: Optional base timestamp. Each message in the batch is stamped - with base + i milliseconds to guarantee unique, monotonic ordering. If - omitted, the server stamps datetime.now(UTC) at insert time. - type: object - required: - - task_id - - contents - title: BatchCreateTaskMessagesRequest - BatchUpdateTaskMessagesRequest: - properties: - task_id: - type: string - title: The unique id of the task to update the messages of - updates: - additionalProperties: - $ref: '#/components/schemas/TaskMessageContent' - type: object - title: The updates to apply to the messages. The key is the TaskMessage - id and the value is the TaskMessageContent to update the message with. - type: object - required: - - task_id - - updates - title: BatchUpdateTaskMessagesRequest - BlobData: - properties: - channel: - type: string - title: Channel name - version: - type: string - title: Channel version - type: - type: string - title: Serialization type tag - blob: - anyOf: - - type: string - - type: 'null' - title: Base64-encoded binary data - type: object - required: - - channel - - version - - type - title: BlobData - BlobResponse: - properties: - channel: - type: string - title: Channel - version: - type: string - title: Version - type: - type: string - title: Type - blob: - anyOf: - - type: string - - type: 'null' - title: Blob - type: object - required: - - channel - - version - - type - title: BlobResponse - CancelTaskRequest: - properties: - task_id: - anyOf: - - type: string - - type: 'null' - title: Task Id - description: The ID of the task to cancel. Either this or task_name must - be provided. - task_name: - anyOf: - - type: string - - type: 'null' - title: Task Name - description: The name of the task to cancel. Either this or task_id must - be provided. - type: object - title: CancelTaskRequest - CheckpointListItem: - properties: - thread_id: - type: string - title: Thread Id - checkpoint_ns: - type: string - title: Checkpoint Ns - checkpoint_id: - type: string - title: Checkpoint Id - parent_checkpoint_id: - anyOf: - - type: string - - type: 'null' - title: Parent Checkpoint Id - checkpoint: - additionalProperties: true - type: object - title: Checkpoint - metadata: - additionalProperties: true - type: object - title: Metadata - type: object - required: - - thread_id - - checkpoint_ns - - checkpoint_id - - checkpoint - - metadata - title: CheckpointListItem - CheckpointTupleResponse: - properties: - thread_id: - type: string - title: Thread Id - checkpoint_ns: - type: string - title: Checkpoint Ns - checkpoint_id: - type: string - title: Checkpoint Id - parent_checkpoint_id: - anyOf: - - type: string - - type: 'null' - title: Parent Checkpoint Id - checkpoint: - additionalProperties: true - type: object - title: Checkpoint - metadata: - additionalProperties: true - type: object - title: Metadata - blobs: - items: - $ref: '#/components/schemas/BlobResponse' - type: array - title: Blobs - pending_writes: - items: - $ref: '#/components/schemas/WriteResponse' - type: array - title: Pending Writes - type: object - required: - - thread_id - - checkpoint_ns - - checkpoint_id - - checkpoint - - metadata - title: CheckpointTupleResponse - CleanTaskRequest: - properties: - force: - type: boolean - title: Force - description: Skip the idle-threshold check. Active-workflow and unprocessed-events - checks still apply. Admin use only. - default: false - idle_days: - type: integer - minimum: 1.0 - title: Idle Days - description: Idle threshold in days (ignored when force=true). - default: 7 - type: object - title: CleanTaskRequest - CleanTaskResponse: - properties: - task_id: - type: string - title: Task Id - cleaned_at: - type: string - format: date-time - title: Cleaned At - messages_deleted: - type: integer - title: Messages Deleted - task_states_deleted: - type: integer - title: Task States Deleted - events_deleted: - type: integer - title: Events Deleted - type: object - required: - - task_id - - cleaned_at - - messages_deleted - - task_states_deleted - - events_deleted - title: CleanTaskResponse - CreateAPIKeyRequest: - properties: - agent_id: - anyOf: - - type: string - - type: 'null' - title: Agent Id - description: The UUID of the agent - agent_name: - anyOf: - - type: string - - type: 'null' - title: Agent Name - description: The name of the agent - if not provided, the agent_id must - be set. - name: - type: string - title: Name - description: The name of the agent's API key. - api_key_type: - $ref: '#/components/schemas/AgentAPIKeyType' - description: The type of the agent API key (external by default). - default: external - api_key: - anyOf: - - type: string - - type: 'null' - title: Api Key - description: Optionally provide the API key value - if not set, one will - be generated. - type: object - required: - - name - title: CreateAPIKeyRequest - CreateAPIKeyResponse: - properties: - id: - type: string - title: Id - description: The unique identifier of the agent API key. - agent_id: - type: string - title: Agent Id - description: The UUID of the agent - created_at: - type: string - format: date-time - title: Created At - description: When the agent API key was created - name: - anyOf: - - type: string - - type: 'null' - title: Name - description: The optional name of the agent API key. - api_key_type: - $ref: '#/components/schemas/AgentAPIKeyType' - description: The type of the created agent API key (external). - api_key: - type: string - title: Api Key - description: The value of the newly created API key. - type: object - required: - - id - - agent_id - - created_at - - name - - api_key_type - - api_key - title: CreateAPIKeyResponse - CreateDeploymentRequest: - properties: - docker_image: - type: string - title: Docker Image - description: Full Docker image URI. - registration_metadata: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Registration Metadata - description: Git/build metadata (commit_hash, branch_name, author_name, - author_email, build_timestamp). - sgp_deploy_id: - anyOf: - - type: string - - type: 'null' - title: Sgp Deploy Id - description: SGP deployment ID. - helm_release_name: - anyOf: - - type: string - - type: 'null' - title: Helm Release Name - description: Helm release name. - type: object - required: - - docker_image - title: CreateDeploymentRequest - CreateScheduleRequest: - properties: - name: - type: string - maxLength: 64 - minLength: 1 - pattern: ^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$ - title: Schedule Name - description: Human-readable name for the schedule (e.g., 'weekly-profiling'). - Will be combined with agent_id to form the full schedule_id. - workflow_name: - type: string - title: Workflow Name - description: Name of the Temporal workflow to execute (e.g., 'sae-orchestrator') - task_queue: - type: string - title: Task Queue - description: Temporal task queue where the agent's worker is listening - workflow_params: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Workflow Parameters - description: Parameters to pass to the workflow - cron_expression: - anyOf: - - type: string - - type: 'null' - title: Cron Expression - description: Cron expression for scheduling (e.g., '0 0 * * 0' for weekly - on Sunday) - interval_seconds: - anyOf: - - type: integer - minimum: 1.0 - - type: 'null' - title: Interval Seconds - description: Alternative to cron - run every N seconds - execution_timeout_seconds: - anyOf: - - type: integer - minimum: 1.0 - - type: 'null' - title: Execution Timeout - description: Maximum time in seconds for each workflow execution - start_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Start At - description: When the schedule should start being active - end_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: End At - description: When the schedule should stop being active - paused: - type: boolean - title: Paused - description: Whether to create the schedule in a paused state - default: false - type: object - required: - - name - - workflow_name - - task_queue - title: CreateScheduleRequest - description: Request model for creating a new schedule for an agent - CreateSpanRequest: - properties: - id: - anyOf: - - type: string - - type: 'null' - title: Unique Span ID - description: Unique identifier for the span. If not provided, an ID will - be generated. - trace_id: - type: string - title: The trace ID for this span - description: Unique identifier for the trace this span belongs to - task_id: - anyOf: - - type: string - - type: 'null' - title: The task ID this span is associated with - description: ID of the task this span belongs to - parent_id: - anyOf: - - type: string - - type: 'null' - title: The parent span ID if this is a child span - description: ID of the parent span if this is a child span in a trace - name: - type: string - title: The name of the span - description: Name that describes what operation this span represents - start_time: - type: string - format: date-time - title: The start time of the span - description: The time the span started - end_time: - anyOf: - - type: string - format: date-time - - type: 'null' - title: The end time of the span - description: The time the span ended - input: - anyOf: - - additionalProperties: true - type: object - - items: - additionalProperties: true - type: object - type: array - - type: 'null' - title: The input data for the span - description: Input parameters or data for the operation - output: - anyOf: - - additionalProperties: true - type: object - - items: - additionalProperties: true - type: object - type: array - - type: 'null' - title: The output data from the span - description: Output data resulting from the operation - data: - anyOf: - - additionalProperties: true - type: object - - items: - additionalProperties: true - type: object - type: array - - type: 'null' - title: Additional data associated with the span - description: Any additional metadata or context for the span - type: object - required: - - trace_id - - name - - start_time - title: CreateSpanRequest - CreateStateRequest: - properties: - task_id: - type: string - title: The unique id of the task to send the state to - agent_id: - type: string - title: The unique id of the agent to send the state to - state: - additionalProperties: true - type: object - title: The state to send to the task. - type: object - required: - - task_id - - agent_id - - state - title: CreateStateRequest - CreateTaskMessageRequest: - properties: - task_id: - type: string - title: The unique id of the task to send the message to - content: - $ref: '#/components/schemas/TaskMessageContent' - title: The message to send to the task. - streaming_status: - anyOf: - - type: string - enum: - - IN_PROGRESS - - DONE - - type: 'null' - title: The streaming status of the message - created_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Optional caller-supplied creation timestamp - description: Optional timestamp for the message. Workflow callers should - pass workflow.now() (Temporal's deterministic monotonic clock) so that - two awaited messages.create calls from the same workflow are guaranteed - to have monotonic timestamps regardless of HTTP scheduling at the server. - If omitted, the server's wall clock at insert time is used. - type: object - required: - - task_id - - content - title: CreateTaskMessageRequest - CreateTaskRequest: - properties: - name: - anyOf: - - type: string - - type: 'null' - title: Name - description: 'Optional human-readable name for the task. When set it must - be globally unique. task/create is get-or-create by name: reusing an existing - name returns the existing task (with its prior history) instead of creating - a new one, so omit name (or make it unique, e.g. by appending a UUID) - whenever each call should produce a fresh task.' - params: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Params - description: The parameters for the task. On a get-or-create by name, providing - params overwrites the existing task's params (it is not a pure read). - task_metadata: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Task Metadata - description: Caller-provided metadata to persist on the task row. Only applied - at task creation; ignored if a task with this name already exists. Forwarded - to the agent inside the ACP payload for backward compatibility. - type: object - title: CreateTaskRequest - DataContent: - properties: - type: - type: string - const: data - title: Type - description: The type of the message, in this case `data`. - default: data - author: - $ref: '#/components/schemas/MessageAuthor' - description: The role of the messages author, in this case `system`, `user`, - `assistant`, or `tool`. - style: - $ref: '#/components/schemas/MessageStyle' - description: The style of the message. This is used by the client to determine - how to display the message. - default: static - data: - additionalProperties: true - type: object - title: Data - description: The contents of the data message. - type: object - required: - - author - - data - title: DataContent - DataContentEntity: - properties: - type: - type: string - const: data - title: Type - description: The type of the message, in this case `data`. - default: data - author: - $ref: '#/components/schemas/MessageAuthor' - description: The role of the messages author, in this case `system`, `user`, - `assistant`, or `tool`. - style: - $ref: '#/components/schemas/MessageStyle' - description: The style of the message. This is used by the client to determine - how to display the message. - default: static - data: - additionalProperties: true - type: object - title: Data - description: The contents of the data message. - type: object - required: - - author - - data - title: DataContentEntity - DataDelta: - properties: - type: - type: string - const: data - title: Type - default: data - data_delta: - anyOf: - - type: string - - type: 'null' - title: Data Delta - default: '' - type: object - title: DataDelta - description: Delta for data updates - DeleteResponse: - properties: - id: - type: string - title: Id - message: - type: string - title: Message - type: object - required: - - id - - message - title: DeleteResponse - DeleteThreadRequest: - properties: - thread_id: - type: string - title: Thread ID - type: object - required: - - thread_id - title: DeleteThreadRequest - Deployment: - properties: - id: - type: string - title: Id - description: The unique identifier of the deployment. - agent_id: - type: string - title: Agent Id - description: The agent this deployment belongs to. - docker_image: - type: string - title: Docker Image - description: Full Docker image URI. - registration_metadata: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Registration Metadata - description: Git/build metadata from the agent pod. - status: - $ref: '#/components/schemas/DeploymentStatus' - description: Current deployment status. - acp_url: - anyOf: - - type: string - - type: 'null' - title: Acp Url - description: ACP URL set when agent registers. - is_production: - type: boolean - title: Is Production - description: Whether this is the production deployment. - sgp_deploy_id: - anyOf: - - type: string - - type: 'null' - title: Sgp Deploy Id - description: Correlates to SGP's agentex_deploys.id. - helm_release_name: - anyOf: - - type: string - - type: 'null' - title: Helm Release Name - description: Helm release name for cleanup. - created_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Created At - description: When the deployment was created. - promoted_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Promoted At - description: When promoted to production. - expires_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Expires At - description: When marked for cleanup. - type: object - required: - - id - - agent_id - - docker_image - - status - - is_production - title: Deployment - DeploymentHistory: - properties: - id: - type: string - title: Id - description: The unique identifier of the deployment record - agent_id: - type: string - title: Agent Id - description: The ID of the agent this deployment belongs to - author_name: - type: string - title: Author Name - description: Name of the commit author - author_email: - type: string - title: Author Email - description: Email of the commit author - branch_name: - type: string - title: Branch Name - description: Name of the branch - build_timestamp: - type: string - format: date-time - title: Build Timestamp - description: When the build was created - deployment_timestamp: - type: string - format: date-time - title: Deployment Timestamp - description: When this deployment was first seen in the system - commit_hash: - type: string - title: Commit Hash - description: Git commit hash for this deployment - type: object - required: - - id - - agent_id - - author_name - - author_email - - branch_name - - build_timestamp - - deployment_timestamp - - commit_hash - title: DeploymentHistory - description: API schema for deployment history. - DeploymentStatus: - type: string - enum: - - Pending - - Ready - - Failed - title: DeploymentStatus - Event: - properties: - id: - type: string - title: Id - description: The UUID of the event - sequence_id: - type: integer - title: Sequence Id - description: The sequence ID of the event - task_id: - type: string - title: Task Id - description: The UUID of the task that the event belongs to - agent_id: - type: string - title: Agent Id - description: The UUID of the agent that the event belongs to - created_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Created At - description: The timestamp of the event - content: - anyOf: - - $ref: '#/components/schemas/TaskMessageContent' - - type: 'null' - description: The content of the event - type: object - required: - - id - - sequence_id - - task_id - - agent_id - title: Event - ExportTaskResponse: - properties: - task_id: - type: string - title: Task Id - messages: - items: - $ref: '#/components/schemas/TaskMessageEntity' - type: array - title: Messages - task_states: - items: - $ref: '#/components/schemas/StateEntity' - type: array - title: Task States - type: object - required: - - task_id - title: ExportTaskResponse - description: Wire format mirrors the entity directly โ€” schema parity is intentional. - ExportTaskToUrlRequest: - properties: - upload_url: - type: string - maxLength: 2083 - minLength: 1 - format: uri - title: Upload Url - description: Presigned PUT URL where Agentex will upload the task snapshot - as JSON. Must be https; must resolve to a public address. - type: object - required: - - upload_url - title: ExportTaskToUrlRequest - ExportTaskToUrlResponse: - properties: - task_id: - type: string - title: Task Id - upload_url: - type: string - title: Upload Url - uploaded_bytes: - type: integer - title: Uploaded Bytes - messages_count: - type: integer - title: Messages Count - task_states_count: - type: integer - title: Task States Count - type: object - required: - - task_id - - upload_url - - uploaded_bytes - - messages_count - - task_states_count - title: ExportTaskToUrlResponse - FileAttachment: - properties: - file_id: - type: string - title: File Id - description: The unique ID of the attached file - name: - type: string - title: Name - description: The name of the file - size: - type: integer - title: Size - description: The size of the file in bytes - type: - type: string - title: Type - description: The MIME type or content type of the file - type: object - required: - - file_id - - name - - size - - type - title: FileAttachment - description: Represents a file attachment in messages. - FileAttachmentEntity: - properties: - file_id: - type: string - title: File Id - description: The unique ID of the attached file - name: - type: string - title: Name - description: The name of the file - size: - type: integer - title: Size - description: The size of the file in bytes - type: - type: string - title: Type - description: The MIME type or content type of the file - type: object - required: - - file_id - - name - - size - - type - title: FileAttachmentEntity - description: Represents a file attachment in messages. - GetCheckpointTupleRequest: - properties: - thread_id: - type: string - title: Thread ID - checkpoint_ns: - type: string - title: Checkpoint namespace - default: '' - checkpoint_id: - anyOf: - - type: string - - type: 'null' - title: Checkpoint ID (None = latest) - type: object - required: - - thread_id - title: GetCheckpointTupleRequest - HTTPValidationError: - properties: - detail: - items: - $ref: '#/components/schemas/ValidationError' - type: array - title: Detail - type: object - title: HTTPValidationError - ListCheckpointsRequest: - properties: - thread_id: - type: string - title: Thread ID - checkpoint_ns: - anyOf: - - type: string - - type: 'null' - title: Checkpoint namespace - before_checkpoint_id: - anyOf: - - type: string - - type: 'null' - title: Before checkpoint ID - filter_metadata: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Metadata filter (JSONB @>) - limit: - type: integer - maximum: 1000.0 - minimum: 1.0 - title: Max results - default: 100 - type: object - required: - - thread_id - title: ListCheckpointsRequest - MessageAuthor: - type: string - enum: - - user - - agent - title: MessageAuthor - MessageStyle: - type: string - enum: - - static - - active - title: MessageStyle - PaginatedMessagesResponse: - properties: - data: - items: - $ref: '#/components/schemas/TaskMessage' - type: array - title: Data - description: List of messages - next_cursor: - anyOf: - - type: string - - type: 'null' - title: Next Cursor - description: Cursor for fetching the next page of older messages - has_more: - type: boolean - title: Has More - description: Whether there are more messages to fetch - default: false - type: object - required: - - data - title: PaginatedMessagesResponse - description: Response with cursor pagination metadata. - PauseScheduleRequest: - properties: - note: - anyOf: - - type: string - - type: 'null' - title: Note - description: Optional note explaining why the schedule was paused - type: object - title: PauseScheduleRequest - description: Request model for pausing a schedule - PutCheckpointRequest: - properties: - thread_id: - type: string - title: Thread ID - checkpoint_ns: - type: string - title: Checkpoint namespace - default: '' - checkpoint_id: - type: string - title: Checkpoint ID - parent_checkpoint_id: - anyOf: - - type: string - - type: 'null' - title: Parent checkpoint ID - checkpoint: - additionalProperties: true - type: object - title: Checkpoint JSONB payload - metadata: - additionalProperties: true - type: object - title: Checkpoint metadata - blobs: - items: - $ref: '#/components/schemas/BlobData' - type: array - title: Channel blob data - type: object - required: - - thread_id - - checkpoint_id - - checkpoint - title: PutCheckpointRequest - PutCheckpointResponse: - properties: - thread_id: - type: string - title: Thread Id - checkpoint_ns: - type: string - title: Checkpoint Ns - checkpoint_id: - type: string - title: Checkpoint Id - type: object - required: - - thread_id - - checkpoint_ns - - checkpoint_id - title: PutCheckpointResponse - PutWritesRequest: - properties: - thread_id: - type: string - title: Thread ID - checkpoint_ns: - type: string - title: Checkpoint namespace - default: '' - checkpoint_id: - type: string - title: Checkpoint ID - writes: - items: - $ref: '#/components/schemas/WriteData' - type: array - title: Write data - upsert: - type: boolean - title: Upsert mode - default: false - type: object - required: - - thread_id - - checkpoint_id - - writes - title: PutWritesRequest - ReasoningContent: - properties: - type: - type: string - const: reasoning - title: Type - description: The type of the message, in this case `reasoning`. - default: reasoning - author: - $ref: '#/components/schemas/MessageAuthor' - description: The role of the messages author, in this case `system`, `user`, - `assistant`, or `tool`. - style: - $ref: '#/components/schemas/MessageStyle' - description: The style of the message. This is used by the client to determine - how to display the message. - default: static - summary: - items: - type: string - type: array - title: Summary - description: A list of short reasoning summaries - content: - anyOf: - - items: - type: string - type: array - - type: 'null' - title: Content - description: The reasoning content or chain-of-thought text - type: object - required: - - author - - summary - title: ReasoningContent - ReasoningContentDelta: - properties: - type: - type: string - const: reasoning_content - title: Type - default: reasoning_content - content_index: - type: integer - title: Content Index - content_delta: - anyOf: - - type: string - - type: 'null' - title: Content Delta - default: '' - type: object - required: - - content_index - title: ReasoningContentDelta - description: Delta for reasoning content updates - ReasoningContentEntity: - properties: - type: - type: string - const: reasoning - title: Type - description: The type of the message, in this case `reasoning`. - default: reasoning - author: - $ref: '#/components/schemas/MessageAuthor' - description: The role of the messages author, in this case `system`, `user`, - `assistant`, or `tool`. - style: - $ref: '#/components/schemas/MessageStyle' - description: The style of the message. This is used by the client to determine - how to display the message. - default: static - summary: - items: - type: string - type: array - title: Summary - description: A list of short reasoning summaries - content: - anyOf: - - items: - type: string - type: array - - type: 'null' - title: Content - description: The reasoning content or chain-of-thought text - type: object - required: - - author - - summary - title: ReasoningContentEntity - ReasoningSummaryDelta: - properties: - type: - type: string - const: reasoning_summary - title: Type - default: reasoning_summary - summary_index: - type: integer - title: Summary Index - summary_delta: - anyOf: - - type: string - - type: 'null' - title: Summary Delta - default: '' - type: object - required: - - summary_index - title: ReasoningSummaryDelta - description: Delta for reasoning summary updates - RegisterAgentRequest: - properties: - name: - type: string - pattern: ^[a-z0-9-]+$ - title: Name - description: The unique name of the agent. - description: - type: string - title: Description - description: The description of the agent. - acp_url: - type: string - title: Acp Url - description: The URL of the ACP server for the agent. - agent_id: - anyOf: - - type: string - - type: 'null' - title: Agent Id - description: Optional agent ID if the agent already exists and needs to - be updated. - acp_type: - $ref: '#/components/schemas/ACPType' - description: The type of ACP to use for the agent. - principal_context: - anyOf: - - {} - - type: 'null' - title: Principal Context - description: Principal used for authorization - registration_metadata: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Registration Metadata - description: The metadata for the agent's registration. - agent_input_type: - anyOf: - - $ref: '#/components/schemas/AgentInputType' - - type: 'null' - description: The type of input the agent expects. - type: object - required: - - name - - description - - acp_url - - acp_type - title: RegisterAgentRequest - RegisterAgentResponse: - properties: - id: - type: string - title: Id - description: The unique identifier of the agent. - name: - type: string - title: Name - description: The unique name of the agent. - description: - type: string - title: Description - description: The description of the action. - status: - $ref: '#/components/schemas/AgentStatus' - description: The status of the action, indicating if it's building, ready, - failed, etc. - default: Unknown - acp_type: - $ref: '#/components/schemas/ACPType' - description: The type of the ACP Server (Either sync or async) - status_reason: - anyOf: - - type: string - - type: 'null' - title: Status Reason - description: The reason for the status of the action. - created_at: - type: string - format: date-time - title: Created At - description: The timestamp when the agent was created - updated_at: - type: string - format: date-time - title: Updated At - description: The timestamp when the agent was last updated - registration_metadata: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Registration Metadata - description: The metadata for the agent's registration. - registered_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Registered At - description: The timestamp when the agent was last registered - agent_input_type: - anyOf: - - $ref: '#/components/schemas/AgentInputType' - - type: 'null' - description: The type of input the agent expects. - production_deployment_id: - anyOf: - - type: string - - type: 'null' - title: Production Deployment Id - description: ID of the current production deployment. - agent_api_key: - anyOf: - - type: string - - type: 'null' - title: Agent Api Key - description: The API key for the agent, if applicable. - type: object - required: - - id - - name - - description - - acp_type - - created_at - - updated_at - title: RegisterAgentResponse - description: Response model for registering an agent. - RegisterBuildRequest: - properties: - name: - type: string - pattern: ^[a-z0-9-]+$ - title: Name - description: The unique name of the agent. - description: - type: string - title: Description - description: The description of the agent. - principal_context: - anyOf: - - {} - - type: 'null' - title: Principal Context - description: Principal used for authorization - registration_metadata: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Registration Metadata - description: The metadata for the agent's build registration. - agent_input_type: - anyOf: - - $ref: '#/components/schemas/AgentInputType' - - type: 'null' - description: The type of input the agent expects. - type: object - required: - - name - - description - title: RegisterBuildRequest - description: 'Request model for registering an agent at build time (pre-deploy). - - - Unlike RegisterAgentRequest, there is no acp_url (the agent is not running - - yet) and no acp_type is required. The created agent is left in BUILD_ONLY - - status so it can be permissioned/shared before it is deployed.' - RehydrateTaskRequest: - properties: - task_id: - type: string - title: Task Id - messages: - items: - $ref: '#/components/schemas/TaskMessageEntity' - type: array - title: Messages - task_states: - items: - $ref: '#/components/schemas/StateEntity' - type: array - title: Task States - snapshot_url: - anyOf: - - type: string - maxLength: 2083 - minLength: 1 - format: uri - - type: 'null' - title: Snapshot Url - description: Presigned GET URL whose body is a JSON-encoded TaskSnapshotEntity. - Must be https; must resolve to a public address. When set, messages/task_states - must be empty. - type: object - required: - - task_id - title: RehydrateTaskRequest - description: 'Either provide inline content (messages + task_states) or a snapshot_url - - pointing at a presigned JSON download. Mixing both is rejected. - - - The inline form is the canonical shape used by export''s GET response, so - - snapshot โ†’ clean โ†’ rehydrate round-trips cleanly without serialization - - changes.' - ScheduleActionInfo: - properties: - workflow_name: - type: string - title: Workflow Name - description: Name of the workflow being executed - workflow_id_prefix: - type: string - title: Workflow ID Prefix - description: Prefix for workflow execution IDs - task_queue: - type: string - title: Task Queue - description: Task queue for the workflow - workflow_params: - anyOf: - - items: {} - type: array - - type: 'null' - title: Workflow Parameters - description: Parameters passed to the workflow - type: object - required: - - workflow_name - - workflow_id_prefix - - task_queue - title: ScheduleActionInfo - description: Information about the scheduled action - ScheduleListItem: - properties: - schedule_id: - type: string - title: Schedule ID - description: Unique identifier for the schedule - name: - type: string - title: Schedule Name - description: Human-readable name for the schedule - agent_id: - type: string - title: Agent ID - description: ID of the agent this schedule belongs to - state: - $ref: '#/components/schemas/ScheduleState' - title: State - description: Current state of the schedule - workflow_name: - anyOf: - - type: string - - type: 'null' - title: Workflow Name - description: Name of the scheduled workflow - next_action_time: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Next Action Time - description: Next scheduled execution time - type: object - required: - - schedule_id - - name - - agent_id - - state - title: ScheduleListItem - description: Abbreviated schedule info for list responses - ScheduleListResponse: - properties: - schedules: - items: - $ref: '#/components/schemas/ScheduleListItem' - type: array - title: Schedules - description: List of schedules - total: - type: integer - title: Total - description: Total number of schedules - type: object - required: - - schedules - - total - title: ScheduleListResponse - description: Response model for listing schedules - ScheduleResponse: - properties: - schedule_id: - type: string - title: Schedule ID - description: Unique identifier for the schedule - name: - type: string - title: Schedule Name - description: Human-readable name for the schedule - agent_id: - type: string - title: Agent ID - description: ID of the agent this schedule belongs to - state: - $ref: '#/components/schemas/ScheduleState' - title: State - description: Current state of the schedule - action: - $ref: '#/components/schemas/ScheduleActionInfo' - title: Action - spec: - $ref: '#/components/schemas/ScheduleSpecInfo' - title: Spec - description: Schedule specification - num_actions_taken: - type: integer - title: Number of Actions Taken - description: Number of times the schedule has executed - default: 0 - num_actions_missed: - type: integer - title: Number of Actions Missed - description: Number of scheduled executions that were missed - default: 0 - next_action_times: - items: - type: string - format: date-time - type: array - title: Next Action Times - description: Upcoming scheduled execution times - last_action_time: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Last Action Time - description: When the schedule last executed - created_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Created At - description: When the schedule was created - type: object - required: - - schedule_id - - name - - agent_id - - state - - action - - spec - title: ScheduleResponse - description: Response model for schedule operations - ScheduleSpecInfo: - properties: - cron_expressions: - items: - type: string - type: array - title: Cron Expressions - description: Cron expressions for the schedule - intervals_seconds: - items: - type: integer - type: array - title: Interval Seconds - description: Interval specifications in seconds - start_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Start At - description: When the schedule starts being active - end_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: End At - description: When the schedule stops being active - type: object - title: ScheduleSpecInfo - description: Information about the schedule specification - ScheduleState: - type: string - enum: - - ACTIVE - - PAUSED - title: ScheduleState - description: Schedule state enum - SendEventRequest: - properties: - task_id: - anyOf: - - type: string - - type: 'null' - title: Task Id - description: The ID of the task that the event was sent to - task_name: - anyOf: - - type: string - - type: 'null' - title: Task Name - description: The name of the task that the event was sent to - content: - anyOf: - - $ref: '#/components/schemas/TaskMessageContent' - - type: 'null' - description: The content to send to the event - type: object - title: SendEventRequest - SendMessageRequest: - properties: - task_id: - anyOf: - - type: string - - type: 'null' - title: Task Id - description: The ID of the task that the message was sent to - task_name: - anyOf: - - type: string - - type: 'null' - title: Task Name - description: The name of the task that the message was sent to - content: - $ref: '#/components/schemas/TaskMessageContent' - description: The message that was sent to the agent - stream: - type: boolean - title: Stream - description: Whether to stream the response message back to the client - default: false - task_params: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Task Params - description: The parameters for the task (only used when creating new tasks) - type: object - required: - - content - title: SendMessageRequest - Span: - properties: - id: - type: string - title: Unique Span ID - trace_id: - type: string - title: The trace ID for this span - description: Unique identifier for the trace this span belongs to - task_id: - anyOf: - - type: string - - type: 'null' - title: The task ID this span is associated with - description: ID of the task this span belongs to - parent_id: - anyOf: - - type: string - - type: 'null' - title: The parent span ID if this is a child span - description: ID of the parent span if this is a child span in a trace - name: - type: string - title: The name of the span - description: Name that describes what operation this span represents - start_time: - type: string - format: date-time - title: The start time of the span - description: The time the span started - end_time: - anyOf: - - type: string - format: date-time - - type: 'null' - title: The end time of the span - description: The time the span ended - input: - anyOf: - - additionalProperties: true - type: object - - items: - additionalProperties: true - type: object - type: array - - type: 'null' - title: The input data for the span - description: Input parameters or data for the operation - output: - anyOf: - - additionalProperties: true - type: object - - items: - additionalProperties: true - type: object - type: array - - type: 'null' - title: The output data from the span - description: Output data resulting from the operation - data: - anyOf: - - additionalProperties: true - type: object - - items: - additionalProperties: true - type: object - type: array - - type: 'null' - title: Additional data associated with the span - description: Any additional metadata or context for the span - type: object - required: - - id - - trace_id - - name - - start_time - title: Span - State: - properties: - task_id: - type: string - title: The unique id of the task to send the state to - agent_id: - type: string - title: The unique id of the agent to send the state to - state: - additionalProperties: true - type: object - title: The state to send to the task. - id: - type: string - title: Id - description: The task state's unique id - created_at: - type: string - format: date-time - title: Created At - description: The timestamp when the state was created - updated_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Updated At - description: The timestamp when the state was last updated - type: object - required: - - task_id - - agent_id - - state - - id - - created_at - title: State - description: 'Represents a state in the agent system. A state is associated - uniquely with a task and an agent. - - - This entity is used to store states in MongoDB, with each state - - associated with a specific task and agent. The combination of task_id and - agent_id is globally unique. - - - The state is a dictionary of arbitrary data.' - StateEntity: - properties: - id: - anyOf: - - type: string - - type: 'null' - title: Id - description: The task state's unique id - task_id: - type: string - title: Task Id - description: ID of the task this state belongs to. The combination of task_id - and agent_id is globally unique. - agent_id: - type: string - title: Agent Id - description: ID of the agent this state belongs to. The combination of task_id - and agent_id is globally unique. - state: - additionalProperties: true - type: object - title: State - description: The state object that contains arbitrary data - created_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Created At - description: The timestamp when the state was created - updated_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Updated At - description: The timestamp when the state was last updated - type: object - required: - - task_id - - agent_id - - state - title: StateEntity - description: 'Represents a state in the agent system. A state is associated - uniquely with a task and an agent. - - - This entity is used to store states in MongoDB, with each state - - associated with a specific task and agent. The combination of task_id and - agent_id is globally unique. - - - The state is a dictionary of arbitrary data.' - StreamTaskMessageDelta: - properties: - type: - type: string - const: delta - title: Type - default: delta - index: - anyOf: - - type: integer - - type: 'null' - title: Index - parent_task_message: - anyOf: - - $ref: '#/components/schemas/TaskMessage' - - type: 'null' - delta: - anyOf: - - $ref: '#/components/schemas/TaskMessageDelta' - - type: 'null' - type: object - title: StreamTaskMessageDelta - description: Event for streaming chunks of content - StreamTaskMessageDone: - properties: - type: - type: string - const: done - title: Type - default: done - index: - anyOf: - - type: integer - - type: 'null' - title: Index - parent_task_message: - anyOf: - - $ref: '#/components/schemas/TaskMessage' - - type: 'null' - type: object - title: StreamTaskMessageDone - description: Event for indicating the task is done - StreamTaskMessageFull: - properties: - type: - type: string - const: full - title: Type - default: full - index: - anyOf: - - type: integer - - type: 'null' - title: Index - parent_task_message: - anyOf: - - $ref: '#/components/schemas/TaskMessage' - - type: 'null' - content: - $ref: '#/components/schemas/TaskMessageContent' - type: object - required: - - content - title: StreamTaskMessageFull - description: Event for streaming the full content - StreamTaskMessageStart: - properties: - type: - type: string - const: start - title: Type - default: start - index: - anyOf: - - type: integer - - type: 'null' - title: Index - parent_task_message: - anyOf: - - $ref: '#/components/schemas/TaskMessage' - - type: 'null' - content: - $ref: '#/components/schemas/TaskMessageContent' - type: object - required: - - content - title: StreamTaskMessageStart - description: Event for starting a streaming message - Task: - properties: - id: - type: string - title: Unique Task ID - name: - anyOf: - - type: string - - type: 'null' - title: Unique name of the task - status: - anyOf: - - $ref: '#/components/schemas/TaskStatus' - - type: 'null' - title: The current status of the task - status_reason: - anyOf: - - type: string - - type: 'null' - title: The reason for the current task status - created_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: The timestamp when the task was created - updated_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: The timestamp when the task was last updated - cleaned_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: The timestamp when the task's content was cleaned for retention compliance; - null when active - params: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Task parameters - task_metadata: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Task metadata - type: object - required: - - id - title: Task - TaskMessage: - properties: - id: - anyOf: - - type: string - - type: 'null' - title: Id - description: The task message's unique id - task_id: - type: string - title: Task Id - description: ID of the task this message belongs to - content: - $ref: '#/components/schemas/TaskMessageContent' - description: The content of the message. This content is not OpenAI compatible. - These are messages that are meant to be displayed to the user. - streaming_status: - anyOf: - - type: string - enum: - - IN_PROGRESS - - DONE - - type: 'null' - title: In case of streaming, this indicates whether the message is still - being streamed or has been completed - created_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Created At - description: The timestamp when the message was created - updated_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Updated At - description: The timestamp when the message was last updated - type: object - required: - - task_id - - content - title: TaskMessage - description: 'Represents a message in the agent system. - - - This entity is used to store messages in MongoDB, with each message - - associated with a specific task.' - TaskMessageContent: - oneOf: - - $ref: '#/components/schemas/TextContent' - - $ref: '#/components/schemas/ReasoningContent' - - $ref: '#/components/schemas/DataContent' - - $ref: '#/components/schemas/ToolRequestContent' - - $ref: '#/components/schemas/ToolResponseContent' - title: TaskMessageContent - discriminator: - propertyName: type - mapping: - data: '#/components/schemas/DataContent' - reasoning: '#/components/schemas/ReasoningContent' - text: '#/components/schemas/TextContent' - tool_request: '#/components/schemas/ToolRequestContent' - tool_response: '#/components/schemas/ToolResponseContent' - TaskMessageDelta: - oneOf: - - $ref: '#/components/schemas/TextDelta' - - $ref: '#/components/schemas/DataDelta' - - $ref: '#/components/schemas/ToolRequestDelta' - - $ref: '#/components/schemas/ToolResponseDelta' - - $ref: '#/components/schemas/ReasoningSummaryDelta' - - $ref: '#/components/schemas/ReasoningContentDelta' - title: TaskMessageDelta - discriminator: - propertyName: type - mapping: - data: '#/components/schemas/DataDelta' - reasoning_content: '#/components/schemas/ReasoningContentDelta' - reasoning_summary: '#/components/schemas/ReasoningSummaryDelta' - text: '#/components/schemas/TextDelta' - tool_request: '#/components/schemas/ToolRequestDelta' - tool_response: '#/components/schemas/ToolResponseDelta' - TaskMessageEntity: - properties: - id: - anyOf: - - type: string - - type: 'null' - title: Id - description: The task message's unique id - task_id: - type: string - title: Task Id - description: ID of the task this message belongs to - content: - oneOf: - - $ref: '#/components/schemas/TextContentEntity' - - $ref: '#/components/schemas/DataContentEntity' - - $ref: '#/components/schemas/ToolRequestContentEntity' - - $ref: '#/components/schemas/ToolResponseContentEntity' - - $ref: '#/components/schemas/ReasoningContentEntity' - title: Content - description: The content of the message. This content is not OpenAI compatible. - These are messages that are meant to be displayed to the user. - discriminator: - propertyName: type - mapping: - data: '#/components/schemas/DataContentEntity' - reasoning: '#/components/schemas/ReasoningContentEntity' - text: '#/components/schemas/TextContentEntity' - tool_request: '#/components/schemas/ToolRequestContentEntity' - tool_response: '#/components/schemas/ToolResponseContentEntity' - streaming_status: - anyOf: - - type: string - enum: - - IN_PROGRESS - - DONE - - type: 'null' - title: In case of streaming, this indicates whether the message is still - being streamed or has been completed - created_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Created At - description: The timestamp when the message was created - updated_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: Updated At - description: The timestamp when the message was last updated - type: object - required: - - task_id - - content - title: TaskMessageEntity - description: 'Represents a message in the agent system. - - - This entity is used to store messages in MongoDB, with each message - - associated with a specific task.' - TaskMessageUpdate: - oneOf: - - $ref: '#/components/schemas/StreamTaskMessageStart' - - $ref: '#/components/schemas/StreamTaskMessageDelta' - - $ref: '#/components/schemas/StreamTaskMessageFull' - - $ref: '#/components/schemas/StreamTaskMessageDone' - title: TaskMessageUpdate - discriminator: - propertyName: type - mapping: - delta: '#/components/schemas/StreamTaskMessageDelta' - done: '#/components/schemas/StreamTaskMessageDone' - full: '#/components/schemas/StreamTaskMessageFull' - start: '#/components/schemas/StreamTaskMessageStart' - TaskRelationships: - type: string - enum: - - agents - title: TaskRelationships - description: Task relationships that can be loaded - TaskResponse: - properties: - id: - type: string - title: Unique Task ID - name: - anyOf: - - type: string - - type: 'null' - title: Unique name of the task - status: - anyOf: - - $ref: '#/components/schemas/TaskStatus' - - type: 'null' - title: The current status of the task - status_reason: - anyOf: - - type: string - - type: 'null' - title: The reason for the current task status - created_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: The timestamp when the task was created - updated_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: The timestamp when the task was last updated - cleaned_at: - anyOf: - - type: string - format: date-time - - type: 'null' - title: The timestamp when the task's content was cleaned for retention compliance; - null when active - params: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Task parameters - task_metadata: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: Task metadata - agents: - anyOf: - - items: - $ref: '#/components/schemas/Agent' - type: array - - type: 'null' - title: Agents associated with this task (only populated when 'agent' view - is requested) - type: object - required: - - id - title: TaskResponse - description: Task response model with optional related data based on relationships - TaskStatus: - type: string - enum: - - CANCELED - - COMPLETED - - FAILED - - RUNNING - - TERMINATED - - TIMED_OUT - - DELETED - title: TaskStatus - TaskStatusReasonRequest: - properties: - reason: - anyOf: - - type: string - - type: 'null' - title: Optional reason for the status change - type: object - title: TaskStatusReasonRequest - TextContent: - properties: - type: - type: string - const: text - title: Type - description: The type of the message, in this case `text`. - default: text - author: - $ref: '#/components/schemas/MessageAuthor' - description: The role of the messages author, in this case `system`, `user`, - `assistant`, or `tool`. - style: - $ref: '#/components/schemas/MessageStyle' - description: The style of the message. This is used by the client to determine - how to display the message. - default: static - format: - $ref: '#/components/schemas/TextFormat' - description: The format of the message. This is used by the client to determine - how to display the message. - default: plain - content: - type: string - title: Content - description: The contents of the text message. - attachments: - anyOf: - - items: - $ref: '#/components/schemas/FileAttachment' - type: array - - type: 'null' - title: Attachments - description: Optional list of file attachments with structured metadata. - type: object - required: - - author - - content - title: TextContent - TextContentEntity: - properties: - type: - type: string - const: text - title: Type - description: The type of the message, in this case `text`. - default: text - author: - $ref: '#/components/schemas/MessageAuthor' - description: The role of the messages author, in this case `system`, `user`, - `assistant`, or `tool`. - style: - $ref: '#/components/schemas/MessageStyle' - description: The style of the message. This is used by the client to determine - how to display the message. - default: static - format: - $ref: '#/components/schemas/TextFormat' - description: The format of the message. This is used by the client to determine - how to display the message. - default: plain - content: - type: string - title: Content - description: The contents of the text message. - attachments: - anyOf: - - items: - $ref: '#/components/schemas/FileAttachmentEntity' - type: array - - type: 'null' - title: Attachments - description: Optional list of file attachments with structured metadata. - type: object - required: - - author - - content - title: TextContentEntity - TextDelta: - properties: - type: - type: string - const: text - title: Type - default: text - text_delta: - anyOf: - - type: string - - type: 'null' - title: Text Delta - default: '' - type: object - title: TextDelta - description: Delta for text updates - TextFormat: - type: string - enum: - - markdown - - plain - - code - title: TextFormat - ToolRequestContent: - properties: - type: - type: string - const: tool_request - title: Type - description: The type of the message, in this case `tool_request`. - default: tool_request - author: - $ref: '#/components/schemas/MessageAuthor' - description: The role of the messages author, in this case `system`, `user`, - `assistant`, or `tool`. - style: - $ref: '#/components/schemas/MessageStyle' - description: The style of the message. This is used by the client to determine - how to display the message. - default: static - tool_call_id: - type: string - title: Tool Call Id - description: The ID of the tool call that is being requested. - name: - type: string - title: Name - description: The name of the tool that is being requested. - arguments: - additionalProperties: true - type: object - title: Arguments - description: The arguments to the tool. - type: object - required: - - author - - tool_call_id - - name - - arguments - title: ToolRequestContent - ToolRequestContentEntity: - properties: - type: - type: string - const: tool_request - title: Type - description: The type of the message, in this case `tool_request`. - default: tool_request - author: - $ref: '#/components/schemas/MessageAuthor' - description: The role of the messages author, in this case `system`, `user`, - `assistant`, or `tool`. - style: - $ref: '#/components/schemas/MessageStyle' - description: The style of the message. This is used by the client to determine - how to display the message. - default: static - tool_call_id: - type: string - title: Tool Call Id - description: The ID of the tool call that is being requested. - name: - type: string - title: Name - description: The name of the tool that is being requested. - arguments: - additionalProperties: true - type: object - title: Arguments - description: The arguments to the tool. - type: object - required: - - author - - tool_call_id - - name - - arguments - title: ToolRequestContentEntity - ToolRequestDelta: - properties: - type: - type: string - const: tool_request - title: Type - default: tool_request - tool_call_id: - type: string - title: Tool Call Id - name: - type: string - title: Name - arguments_delta: - anyOf: - - type: string - - type: 'null' - title: Arguments Delta - default: '' - type: object - required: - - tool_call_id - - name - title: ToolRequestDelta - description: Delta for tool request updates - ToolResponseContent: - properties: - type: - type: string - const: tool_response - title: Type - description: The type of the message, in this case `tool_response`. - default: tool_response - author: - $ref: '#/components/schemas/MessageAuthor' - description: The role of the messages author, in this case `system`, `user`, - `assistant`, or `tool`. - style: - $ref: '#/components/schemas/MessageStyle' - description: The style of the message. This is used by the client to determine - how to display the message. - default: static - tool_call_id: - type: string - title: Tool Call Id - description: The ID of the tool call that is being responded to. - name: - type: string - title: Name - description: The name of the tool that is being responded to. - content: - title: Content - description: The result of the tool. - type: object - required: - - author - - tool_call_id - - name - - content - title: ToolResponseContent - ToolResponseContentEntity: - properties: - type: - type: string - const: tool_response - title: Type - description: The type of the message, in this case `tool_response`. - default: tool_response - author: - $ref: '#/components/schemas/MessageAuthor' - description: The role of the messages author, in this case `system`, `user`, - `assistant`, or `tool`. - style: - $ref: '#/components/schemas/MessageStyle' - description: The style of the message. This is used by the client to determine - how to display the message. - default: static - tool_call_id: - type: string - title: Tool Call Id - description: The ID of the tool call that is being responded to. - name: - type: string - title: Name - description: The name of the tool that is being responded to. - content: - title: Content - description: The result of the tool. - type: object - required: - - author - - tool_call_id - - name - - content - title: ToolResponseContentEntity - ToolResponseDelta: - properties: - type: - type: string - const: tool_response - title: Type - default: tool_response - tool_call_id: - type: string - title: Tool Call Id - name: - type: string - title: Name - content_delta: - anyOf: - - type: string - - type: 'null' - title: Content Delta - default: '' - type: object - required: - - tool_call_id - - name - title: ToolResponseDelta - description: Delta for tool response updates - UnpauseScheduleRequest: - properties: - note: - anyOf: - - type: string - - type: 'null' - title: Note - description: Optional note explaining why the schedule was unpaused - type: object - title: UnpauseScheduleRequest - description: Request model for unpausing a schedule - UpdateAgentTaskTrackerRequest: - properties: - last_processed_event_id: - anyOf: - - type: string - - type: 'null' - title: Last Processed Event Id - description: The most recent processed event ID (omit to leave unchanged) - status: - anyOf: - - type: string - - type: 'null' - title: Status - description: Processing status - status_reason: - anyOf: - - type: string - - type: 'null' - title: Status Reason - description: Optional status reason - type: object - title: UpdateAgentTaskTrackerRequest - description: Request model for updating an agent task tracker. - UpdateSpanRequest: - properties: - trace_id: - anyOf: - - type: string - - type: 'null' - title: The trace ID for this span - description: Unique identifier for the trace this span belongs to - task_id: - anyOf: - - type: string - - type: 'null' - title: The task ID this span is associated with - description: ID of the task this span belongs to - parent_id: - anyOf: - - type: string - - type: 'null' - title: The parent span ID if this is a child span - description: ID of the parent span if this is a child span in a trace - name: - anyOf: - - type: string - - type: 'null' - title: The name of the span - description: Name that describes what operation this span represents - start_time: - anyOf: - - type: string - format: date-time - - type: 'null' - title: The start time of the span - description: The time the span started - end_time: - anyOf: - - type: string - format: date-time - - type: 'null' - title: The end time of the span - description: The time the span ended - input: - anyOf: - - additionalProperties: true - type: object - - items: - additionalProperties: true - type: object - type: array - - type: 'null' - title: The input data for the span - description: Input parameters or data for the operation - output: - anyOf: - - additionalProperties: true - type: object - - items: - additionalProperties: true - type: object - type: array - - type: 'null' - title: The output data from the span - description: Output data resulting from the operation - data: - anyOf: - - additionalProperties: true - type: object - - items: - additionalProperties: true - type: object - type: array - - type: 'null' - title: Additional data associated with the span - description: Any additional metadata or context for the span - type: object - title: UpdateSpanRequest - UpdateStateRequest: - properties: - task_id: - type: string - title: The unique id of the task to update the state of - agent_id: - type: string - title: The unique id of the agent to update the state of - state: - additionalProperties: true - type: object - title: The state to update the state with. - type: object - required: - - task_id - - agent_id - - state - title: UpdateStateRequest - UpdateTaskMessageRequest: - properties: - task_id: - type: string - title: The unique id of the task to update the message of - content: - $ref: '#/components/schemas/TaskMessageContent' - title: The message to update the message with. - streaming_status: - anyOf: - - type: string - enum: - - IN_PROGRESS - - DONE - - type: 'null' - title: The streaming status of the message - type: object - required: - - task_id - - content - title: UpdateTaskMessageRequest - UpdateTaskRequest: - properties: - task_metadata: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - title: If provided, replaces task_metadata with this value - type: object - title: UpdateTaskRequest - ValidationError: - properties: - loc: - items: - anyOf: - - type: string - - type: integer - type: array - title: Location - msg: - type: string - title: Message - type: - type: string - title: Error Type - input: - title: Input - ctx: - type: object - title: Context - type: object - required: - - loc - - msg - - type - title: ValidationError - WriteData: - properties: - task_id: - type: string - title: Task ID - idx: - type: integer - title: Write index - channel: - type: string - title: Channel name - type: - anyOf: - - type: string - - type: 'null' - title: Serialization type tag - blob: - type: string - title: Base64-encoded binary data - task_path: - type: string - title: Task path - default: '' - type: object - required: - - task_id - - idx - - channel - - blob - title: WriteData - WriteResponse: - properties: - task_id: - type: string - title: Task Id - idx: - type: integer - title: Idx - channel: - type: string - title: Channel - type: - anyOf: - - type: string - - type: 'null' - title: Type - blob: - anyOf: - - type: string - - type: 'null' - title: Blob - type: object - required: - - task_id - - idx - - channel - title: WriteResponse diff --git a/tests/compat/test_request_compat.py b/tests/compat/test_request_compat.py deleted file mode 100644 index 9a6a62deb..000000000 --- a/tests/compat/test_request_compat.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Validate that ADK requests stay valid against a window of supported server -OpenAPI contracts (server_specs/); see server_specs/manifest.json for the window.""" - -from __future__ import annotations - -import json -from typing import Any -from pathlib import Path -from unittest.mock import Mock - -import yaml -import httpx -import pytest -from jsonschema import Draft202012Validator -from referencing import Registry, Resource -from referencing.jsonschema import DRAFT202012 - -from agentex import AsyncAgentex -from agentex.lib.core.services.adk.state import StateService - -_SPEC_DIR = Path(__file__).parent / "server_specs" -_BASE_URL = "http://127.0.0.1:4010" -_STATE_RESPONSE = { - "id": "s1", - "agent_id": "a1", - "task_id": "t1", - "state": {"k": "v"}, - "created_at": "2026-05-13T18:30:00Z", -} - - -def _window() -> list[dict[str, Any]]: - manifest = json.loads((_SPEC_DIR / "manifest.json").read_text()) - return [ - {"label": e["label"], "spec": yaml.safe_load((_SPEC_DIR / e["file"]).read_text())} for e in manifest["specs"] - ] - - -def _mock_span(): - span = Mock() - span.output = None - - async def __aenter__(_self): - return span - - async def __aexit__(_self, *args): - return None - - span.__aenter__ = __aenter__ - span.__aexit__ = __aexit__ - return span - - -def _state_service(client: AsyncAgentex) -> StateService: - tracer = Mock() - trace = Mock() - trace.span.return_value = _mock_span() - tracer.trace.return_value = trace - return StateService(agentex_client=client, tracer=tracer) - - -async def _drive_update(svc: StateService) -> None: - await svc.update_state(state_id="s1", task_id="t1", agent_id="a1", state={"k": "v"}) - - -async def _drive_create(svc: StateService) -> None: - await svc.create_state(task_id="t1", agent_id="a1", state={"k": "v"}) - - -# Each ADK operation: how to drive it, the request it emits, and the server-spec -# operation (path template + method) whose requestBody its body must satisfy. -_OPERATIONS = [ - { - "label": "states.update", - "http_method": "PUT", - "url": f"{_BASE_URL}/states/s1", - "spec_path": "/states/{state_id}", - "spec_method": "put", - "drive": _drive_update, - }, - { - "label": "states.create", - "http_method": "POST", - "url": f"{_BASE_URL}/states", - "spec_path": "/states", - "spec_method": "post", - "drive": _drive_create, - }, -] - -_WINDOW = _window() - - -def _deref(schema: dict[str, Any], spec: dict[str, Any]) -> dict[str, Any]: - ref = schema.get("$ref") - if not ref or not ref.startswith("#/"): - return schema - node: Any = spec - for part in ref[2:].split("/"): - node = node[part] - return node - - -def _request_body_schema(spec: dict[str, Any], path: str, method: str) -> dict[str, Any]: - schema = spec["paths"][path][method]["requestBody"]["content"]["application/json"]["schema"] - return _deref(schema, spec) - - -@pytest.mark.parametrize("entry", _WINDOW, ids=lambda e: e["label"]) -@pytest.mark.parametrize("op", _OPERATIONS, ids=lambda o: o["label"]) -async def test_adk_request_validates_against_server_spec( - op: dict[str, Any], entry: dict[str, Any], respx_mock: Any -) -> None: - route = respx_mock.route(method=op["http_method"], url=op["url"]).mock( - return_value=httpx.Response(200, json=_STATE_RESPONSE) - ) - async with AsyncAgentex(base_url=_BASE_URL, api_key="test") as client: - await op["drive"](_state_service(client)) - - assert route.called, f"{op['label']} did not hit {op['url']}" - body = json.loads(route.calls.last.request.content) - - spec = entry["spec"] - registry = Registry().with_resource(uri="", resource=Resource(contents=spec, specification=DRAFT202012)) - schema = _request_body_schema(spec, op["spec_path"], op["spec_method"]) - errors = sorted(Draft202012Validator(schema, registry=registry).iter_errors(body), key=str) - assert not errors, f"{op['label']} request {body} violates server contract '{entry['label']}': " + "; ".join( - e.message for e in errors - ) diff --git a/tests/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_litellm_usage.py b/tests/lib/adk/providers/test_litellm_usage.py deleted file mode 100644 index 5f5d480d9..000000000 --- a/tests/lib/adk/providers/test_litellm_usage.py +++ /dev/null @@ -1,219 +0,0 @@ -"""Tests that LiteLLMService puts LLM token usage on spans for billing. - -Covers the paths that previously dropped usage: both auto_send variants (span -output was only the TaskMessage dump) and streaming (litellm omits usage unless -``stream_options.include_usage`` is set). -""" - -from __future__ import annotations - -from typing import Any -from datetime import UTC, datetime -from contextlib import asynccontextmanager -from unittest.mock import AsyncMock, MagicMock - -from agentex.types.span import Span -from agentex.types.task_message import TaskMessage -from agentex.lib.types.llm_messages import ( - Delta, - Usage, - Choice, - LLMConfig, - Completion, - AssistantMessage, -) -from agentex.types.task_message_content import TextContent -from agentex.lib.core.services.adk.providers.litellm import ( - LiteLLMService, - _stream_kwargs_with_usage, -) - - -class FakeTrace: - def __init__(self) -> None: - self.spans: list[Span] = [] - - @asynccontextmanager - async def span(self, name, parent_id=None, input=None, data=None, task_id=None): - span = Span( - id=f"span-{len(self.spans)}", - name=name, - start_time=datetime.now(UTC), - trace_id="trace-1", - parent_id=parent_id, - input=input, - ) - self.spans.append(span) - yield span - - -class FakeTracer: - def __init__(self) -> None: - self.trace_obj = FakeTrace() - - def trace(self, trace_id): - return self.trace_obj - - -def _output_dict(span: Span) -> dict[str, Any]: - assert isinstance(span.output, dict) - return span.output - - -def _make_streaming_service(): - streaming_context = MagicMock() - streaming_context.task_message = TaskMessage( - id="msg-1", - task_id="task-1", - content=TextContent(author="agent", content="", format="markdown"), - ) - streaming_context.stream_update = AsyncMock() - - @asynccontextmanager - async def fake_context(**kwargs): - yield streaming_context - - streaming_service = MagicMock() - streaming_service.streaming_task_message_context = fake_context - return streaming_service, streaming_context - - -def _make_service(llm_gateway) -> tuple[LiteLLMService, FakeTracer]: - streaming_service, _ = _make_streaming_service() - tracer = FakeTracer() - service = LiteLLMService( - agentex_client=MagicMock(), - streaming_service=streaming_service, - tracer=tracer, - llm_gateway=llm_gateway, - ) - return service, tracer - - -def _stream_gateway(chunks, captured_kwargs): - gateway = MagicMock() - - def acompletion_stream(**kwargs): - captured_kwargs.update(kwargs) - - async def stream(): - for chunk in chunks: - yield chunk - - return stream() - - gateway.acompletion_stream = acompletion_stream - return gateway - - -def _delta_chunk(content: str, role: str | None = None) -> Completion: - return Completion(choices=[Choice(index=0, delta=Delta(content=content, role=role))]) - - -def _usage_only_chunk() -> Completion: - return Completion( - choices=[], - usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15), - ) - - -class TestStreamKwargsWithUsage: - def test_defaults_include_usage_on(self): - config = LLMConfig(model="gpt-4o", messages=[], stream=True) - assert _stream_kwargs_with_usage(config)["stream_options"] == {"include_usage": True} - - def test_caller_opt_out_preserved(self): - config = LLMConfig(model="gpt-4o", messages=[], stream=True, stream_options={"include_usage": False}) - assert _stream_kwargs_with_usage(config)["stream_options"] == {"include_usage": False} - - def test_merges_with_other_stream_options(self): - config = LLMConfig(model="gpt-4o", messages=[], stream=True, stream_options={"other": 1}) - assert _stream_kwargs_with_usage(config)["stream_options"] == {"include_usage": True, "other": 1} - - -class TestChatCompletionAutoSend: - async def test_span_output_carries_usage(self): - completion = Completion( - choices=[Choice(index=0, message=AssistantMessage(content="Hello!"), finish_reason="stop")], - usage=Usage(prompt_tokens=7, completion_tokens=3, total_tokens=10), - ) - gateway = MagicMock() - gateway.acompletion = AsyncMock(return_value=completion) - service, tracer = _make_service(gateway) - - await service.chat_completion_auto_send( - task_id="task-1", - llm_config=LLMConfig(model="gpt-4o", messages=[], stream=False), - trace_id="trace-1", - ) - - span = tracer.trace_obj.spans[0] - assert _output_dict(span)["usage"] == {"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10} - - async def test_span_output_omits_usage_when_absent(self): - completion = Completion( - choices=[Choice(index=0, message=AssistantMessage(content="Hello!"), finish_reason="stop")], - ) - gateway = MagicMock() - gateway.acompletion = AsyncMock(return_value=completion) - service, tracer = _make_service(gateway) - - await service.chat_completion_auto_send( - task_id="task-1", - llm_config=LLMConfig(model="gpt-4o", messages=[], stream=False), - trace_id="trace-1", - ) - - assert "usage" not in _output_dict(tracer.trace_obj.spans[0]) - - -class TestChatCompletionStream: - async def test_stream_requests_usage_and_span_output_carries_it(self): - captured_kwargs: dict = {} - chunks = [_delta_chunk("Hel", role="assistant"), _delta_chunk("lo!"), _usage_only_chunk()] - service, tracer = _make_service(_stream_gateway(chunks, captured_kwargs)) - - results = [] - async for chunk in service.chat_completion_stream( - llm_config=LLMConfig(model="gpt-4o", messages=[], stream=True), - trace_id="trace-1", - ): - results.append(chunk) - - assert len(results) == 3 - assert captured_kwargs["stream_options"] == {"include_usage": True} - span = tracer.trace_obj.spans[0] - assert _output_dict(span)["usage"] == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} - assert _output_dict(span)["choices"][0]["message"]["content"] == "Hello!" - - -class TestChatCompletionStreamAutoSend: - async def test_usage_only_final_chunk_reaches_span_output(self): - captured_kwargs: dict = {} - chunks = [_delta_chunk("Hel", role="assistant"), _delta_chunk("lo!"), _usage_only_chunk()] - service, tracer = _make_service(_stream_gateway(chunks, captured_kwargs)) - - await service.chat_completion_stream_auto_send( - task_id="task-1", - llm_config=LLMConfig(model="gpt-4o", messages=[], stream=True), - trace_id="trace-1", - ) - - assert captured_kwargs["stream_options"] == {"include_usage": True} - span = tracer.trace_obj.spans[0] - assert _output_dict(span)["usage"] == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} - # TaskMessage dump is still the base of the span output - assert _output_dict(span)["id"] == "msg-1" - - async def test_stream_without_usage_chunk_omits_usage(self): - captured_kwargs: dict = {} - chunks = [_delta_chunk("Hi", role="assistant")] - service, tracer = _make_service(_stream_gateway(chunks, captured_kwargs)) - - await service.chat_completion_stream_auto_send( - task_id="task-1", - llm_config=LLMConfig(model="gpt-4o", messages=[], stream=True), - trace_id="trace-1", - ) - - assert "usage" not in _output_dict(tracer.trace_obj.spans[0]) diff --git a/tests/lib/adk/providers/test_openai_activities.py b/tests/lib/adk/providers/test_openai_activities.py deleted file mode 100644 index 964b24545..000000000 --- a/tests/lib/adk/providers/test_openai_activities.py +++ /dev/null @@ -1,843 +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) - - # Under the unified harness, the OpenAI events are converted to canonical - # StreamTaskMessageFull events and auto_send posts each full tool message - # by opening a streaming context with the content as initial_content and - # closing it (no stream_update). So assert on the opened contents. - opened = mock_streaming_context.opened_contents - tool_contents = [c for c in opened if getattr(c, "type", None) in ("tool_request", "tool_response")] - assert len(tool_contents) == 2 - - # First opened context is the tool request. - first = tool_contents[0] - assert first.type == "tool_request" - assert first.name == "code_interpreter" - assert first.tool_call_id == "code_interpreter_call_123" - - # Second opened context is the tool response. - second = tool_contents[1] - assert second.type == "tool_response" - assert second.tool_call_id == "code_interpreter_call_123" - - @patch("agents.Runner.run_streamed") - async def test_run_agent_streamed_auto_send_forwards_previous_response_id(self, mock_runner_run_streamed): - """previous_response_id must reach Runner.run_streamed so a Responses-API - conversation continues instead of silently starting fresh.""" - from agentex.lib.core.temporal.activities.adk.providers.openai_activities import ( - RunAgentStreamedAutoSendParams, - ) - - mock_streaming_result = self._create_streaming_result_mock() - - async def _no_events(): - return - yield - - mock_streaming_result.stream_events = _no_events - mock_runner_run_streamed.return_value = mock_streaming_result - - mock_tracer = self._create_mock_tracer() - openai_service, openai_activities, env = self._create_test_setup(mock_tracer) - self._setup_streaming_service_mocks(openai_service) - - params = RunAgentStreamedAutoSendParams( - input_list=[{"role": "user", "content": "continue"}], - mcp_server_params=[], - agent_name="test_agent", - agent_instructions="You are a helpful assistant", - trace_id="test-trace-id", - parent_span_id="test-span-id", - task_id="test-task-id", - previous_response_id="response_123", - ) - - await env.run(openai_activities.run_agent_streamed_auto_send, params) - - mock_runner_run_streamed.assert_called_once() - assert mock_runner_run_streamed.call_args.kwargs.get("previous_response_id") == "response_123" - - def _create_mock_tracer(self): - """Helper method to create a properly mocked tracer with async context manager support.""" - mock_tracer = Mock() - mock_trace = Mock() - mock_span = Mock() - - # Setup the span context manager - async def mock_span_aenter(_): - return mock_span - - async def mock_span_aexit(_, _exc_type, _exc_val, _exc_tb): - return None - - mock_span.__aenter__ = mock_span_aenter - mock_span.__aexit__ = mock_span_aexit - mock_trace.span.return_value = mock_span - mock_tracer.trace.return_value = mock_trace - - return mock_tracer - - def _create_test_setup(self, mock_tracer): - """Helper method to create OpenAIService and OpenAIActivities instances.""" - # Import here to avoid circular imports - from agentex.lib.core.services.adk.providers.openai import OpenAIService - from agentex.lib.core.temporal.activities.adk.providers.openai_activities import OpenAIActivities - - openai_service = OpenAIService(tracer=mock_tracer) - openai_activities = OpenAIActivities(openai_service) - env = ActivityEnvironment() - - return openai_service, openai_activities, env - - def _assert_runner_call_signature(self, call_args): - """Helper method to validate Runner.run call signature.""" - actual_kwargs = set(call_args.kwargs.keys()) - - # Check that we only pass valid Runner.run parameters - valid_params = { - "starting_agent", - "input", - "context", - "max_turns", - "hooks", - "run_config", - "previous_response_id", - "session", - } - invalid_kwargs = actual_kwargs - valid_params - assert not invalid_kwargs, f"Invalid arguments passed to Runner.run: {invalid_kwargs}" - - # Verify required arguments are present - assert "starting_agent" in call_args.kwargs, "starting_agent is required for Runner.run" - assert "input" in call_args.kwargs, "input is required for Runner.run" - - # Verify starting_agent is not None (actual agent object created) - assert call_args.kwargs["starting_agent"] is not None, "starting_agent should not be None" - - def _assert_runner_call_signature_streamed(self, call_args): - """Helper method to validate Runner.run_streamed call signature.""" - actual_kwargs = set(call_args.kwargs.keys()) - - # Check that we only pass valid Runner.run_streamed parameters - valid_params = { - "starting_agent", - "input", - "context", - "max_turns", - "hooks", - "run_config", - "previous_response_id", - "session", - } - invalid_kwargs = actual_kwargs - valid_params - assert not invalid_kwargs, f"Invalid arguments passed to Runner.run_streamed: {invalid_kwargs}" - - # Verify required arguments are present - assert "starting_agent" in call_args.kwargs, "starting_agent is required for Runner.run_streamed" - assert "input" in call_args.kwargs, "input is required for Runner.run_streamed" - - # Verify starting_agent is not None (actual agent object created) - assert call_args.kwargs["starting_agent"] is not None, "starting_agent should not be None" - - def _assert_starting_agent_params(self, starting_agent, expected_params): - """Helper method to validate starting_agent parameters match expected values.""" - # Verify agent name and instructions match - assert starting_agent.name == expected_params.agent_name, f"Agent name should be {expected_params.agent_name}" - assert starting_agent.instructions == expected_params.agent_instructions, f"Agent instructions should match" - - # Note: Other agent parameters like tools, guardrails would be tested here - # but they require more complex inspection of the agent object - - def _assert_result_structure(self, result, expected_output="Hello! How can I help you today?"): - """Helper method to validate the result structure.""" - from agentex.lib.types.agent_results import SerializableRunResult - - assert isinstance(result, SerializableRunResult) - assert result.final_output == expected_output - assert len(result.final_input_list) == 2 - assert result.final_input_list[0]["role"] == "user" - assert result.final_input_list[1]["role"] == "assistant" - - def _create_tools_for_case(self, tools_case): - """Helper method to create tools based on test case.""" - from agentex.lib.core.temporal.activities.adk.providers.openai_activities import ( - ComputerTool, - FunctionTool, - WebSearchTool, - FileSearchTool, - LocalShellTool, - CodeInterpreterTool, - ImageGenerationTool, - ) - - def sample_tool_function(_context, args): - return f"Tool called with {args}" - - def sample_computer(): - return Mock() # Mock computer object - - def sample_safety_check(_data): - return True - - def sample_executor(): - return Mock() # Mock executor - - if tools_case == "no_tools": - return None - elif tools_case == "function_tool": - return [ - FunctionTool( - name="test_function", - description="A test function tool", - params_json_schema={"type": "object", "properties": {}}, - on_invoke_tool=sample_tool_function, - ) - ] - elif tools_case == "web_search_tool": - return [WebSearchTool()] - elif tools_case == "file_search_tool": - return [ - FileSearchTool(vector_store_ids=["store1", "store2"], max_num_results=10, include_search_results=True) - ] - elif tools_case == "computer_tool": - return [ComputerTool(computer=sample_computer(), on_safety_check=sample_safety_check)] - elif tools_case == "code_interpreter_tool": - return [ - CodeInterpreterTool( - tool_config={"type": "code_interpreter", "container": {"type": "static", "image": "python:3.11"}} - ) - ] - elif tools_case == "image_generation_tool": - return [ - ImageGenerationTool( - tool_config={ - "type": "image_generation", - "quality": "high", - "size": "1024x1024", - "output_format": "png", - } - ) - ] - elif tools_case == "local_shell_tool": - return [LocalShellTool(executor=sample_executor())] - elif tools_case == "mixed_tools": - return [ - FunctionTool( - name="calculator", - description="A calculator tool", - params_json_schema={"type": "object", "properties": {"expression": {"type": "string"}}}, - on_invoke_tool=sample_tool_function, - ), - WebSearchTool(), - FileSearchTool(vector_store_ids=["store1"], max_num_results=5), - ] - else: - raise ValueError(f"Unknown tools_case: {tools_case}") - - def _assert_tools_conversion(self, starting_agent, tools_case, _original_tools): - """Helper method to validate that tools were properly converted.""" - from agents.tool import ( - ComputerTool as OAIComputerTool, - FunctionTool as OAIFunctionTool, - WebSearchTool as OAIWebSearchTool, - FileSearchTool as OAIFileSearchTool, - LocalShellTool as OAILocalShellTool, - CodeInterpreterTool as OAICodeInterpreterTool, - ImageGenerationTool as OAIImageGenerationTool, - ) - - if tools_case == "no_tools": - # When no tools are provided, the agent should have an empty tools list - assert starting_agent.tools == [], "Agent should have empty tools list when no tools provided" - - elif tools_case == "function_tool": - assert len(starting_agent.tools) == 1, "Agent should have 1 tool" - agent_tool = starting_agent.tools[0] - assert isinstance(agent_tool, OAIFunctionTool), "Tool should be converted to OAIFunctionTool" - assert agent_tool.name == "test_function", "Tool name should be preserved" - assert agent_tool.description == "A test function tool", "Tool description should be preserved" - # Check that the schema contains our expected fields (may have additional fields) - assert "type" in agent_tool.params_json_schema, "Tool schema should have type field" - assert agent_tool.params_json_schema["type"] == "object", "Tool schema type should be object" - assert "properties" in agent_tool.params_json_schema, "Tool schema should have properties field" - assert callable(agent_tool.on_invoke_tool), "Tool function should be callable" - - elif tools_case == "web_search_tool": - assert len(starting_agent.tools) == 1, "Agent should have 1 tool" - agent_tool = starting_agent.tools[0] - assert isinstance(agent_tool, OAIWebSearchTool), "Tool should be converted to OAIWebSearchTool" - - elif tools_case == "file_search_tool": - assert len(starting_agent.tools) == 1, "Agent should have 1 tool" - agent_tool = starting_agent.tools[0] - assert isinstance(agent_tool, OAIFileSearchTool), "Tool should be converted to OAIFileSearchTool" - assert agent_tool.vector_store_ids == ["store1", "store2"], "Vector store IDs should be preserved" - assert agent_tool.max_num_results == 10, "Max results should be preserved" - assert agent_tool.include_search_results, "Include search results flag should be preserved" - - elif tools_case == "computer_tool": - assert len(starting_agent.tools) == 1, "Agent should have 1 tool" - agent_tool = starting_agent.tools[0] - assert isinstance(agent_tool, OAIComputerTool), "Tool should be converted to OAIComputerTool" - assert agent_tool.computer is not None, "Computer object should be present" - assert agent_tool.on_safety_check is not None, "Safety check function should be present" - - elif tools_case == "code_interpreter_tool": - assert len(starting_agent.tools) == 1, "Agent should have 1 tool" - agent_tool = starting_agent.tools[0] - assert isinstance(agent_tool, OAICodeInterpreterTool), "Tool should be converted to OAICodeInterpreterTool" - - elif tools_case == "image_generation_tool": - assert len(starting_agent.tools) == 1, "Agent should have 1 tool" - agent_tool = starting_agent.tools[0] - assert isinstance(agent_tool, OAIImageGenerationTool), "Tool should be converted to OAIImageGenerationTool" - - elif tools_case == "local_shell_tool": - assert len(starting_agent.tools) == 1, "Agent should have 1 tool" - agent_tool = starting_agent.tools[0] - assert isinstance(agent_tool, OAILocalShellTool), "Tool should be converted to OAILocalShellTool" - assert agent_tool.executor is not None, "Executor should be present" - - elif tools_case == "mixed_tools": - assert len(starting_agent.tools) == 3, "Agent should have 3 tools" - - # Check first tool (FunctionTool) - function_tool = starting_agent.tools[0] - assert isinstance(function_tool, OAIFunctionTool), "First tool should be OAIFunctionTool" - assert function_tool.name == "calculator", "Function tool name should be preserved" - - # Check second tool (WebSearchTool) - web_tool = starting_agent.tools[1] - assert isinstance(web_tool, OAIWebSearchTool), "Second tool should be OAIWebSearchTool" - - # Check third tool (FileSearchTool) - file_tool = starting_agent.tools[2] - assert isinstance(file_tool, OAIFileSearchTool), "Third tool should be OAIFileSearchTool" - - else: - raise ValueError(f"Unknown tools_case: {tools_case}") - - @patch("agents.Runner.run_streamed") - async def test_run_agent_streamed_auto_send_forwards_created_at(self, mock_runner_run_streamed): - """created_at is forwarded to every streaming context opened by auto_send_turn.""" - from datetime import datetime, timezone - - from agentex.lib.core.temporal.activities.adk.providers.openai_activities import ( - RunAgentStreamedAutoSendParams, - ) - - deterministic_ts = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc) - - mock_streaming_result = self._create_streaming_result_mock() - - # Emit a tool call + tool response so auto_send actually opens streaming - # contexts; an empty stream opens none, making the assertion below - # vacuously true and unable to catch a created_at regression. - async def mock_stream_events(): - tool_call_event = Mock() - tool_call_event.type = "run_item_stream_event" - tool_call_event.item = self._create_tool_call_item_mock(self._create_code_interpreter_tool_call_mock()) - yield tool_call_event - - tool_response_event = Mock() - tool_response_event.type = "run_item_stream_event" - tool_response_event.item = self._create_tool_output_item_mock() - yield tool_response_event - - mock_streaming_result.stream_events = mock_stream_events - mock_runner_run_streamed.return_value = mock_streaming_result - - mock_tracer = self._create_mock_tracer() - openai_service, openai_activities, env = self._create_test_setup(mock_tracer) - mock_ctx, recorded_created_ats = self._setup_streaming_service_mocks_with_created_at(openai_service) - - params = RunAgentStreamedAutoSendParams( - input_list=[{"role": "user", "content": "hello"}], - mcp_server_params=[], - agent_name="test_agent", - agent_instructions="You are a helpful assistant", - trace_id="test-trace-id", - parent_span_id="test-span-id", - task_id="test-task-id", - created_at=deterministic_ts, - ) - - await env.run(openai_activities.run_agent_streamed_auto_send, params) - - # Guard against a vacuous pass: at least one streaming context must have - # been opened so the per-context created_at assertion is meaningful. - assert recorded_created_ats, "expected at least one streaming context to be opened" - assert all(ts == deterministic_ts for ts in recorded_created_ats), ( - f"Expected all streaming contexts to receive created_at={deterministic_ts!r}, got: {recorded_created_ats!r}" - ) - - def _setup_streaming_service_mocks(self, openai_service): - """Helper method to setup streaming service mocks for run_agent_auto_send.""" - from unittest.mock import AsyncMock - - # Mock the streaming service and agentex client - mock_streaming_service = AsyncMock() - mock_agentex_client = AsyncMock() - - # Mock streaming context manager - mock_streaming_context = AsyncMock() - - # Create a proper TaskMessage mock that passes validation - from agentex.types.task_message import TaskMessage - - mock_task_message = Mock(spec=TaskMessage) - mock_task_message.id = "test-task-message-id" - mock_task_message.task_id = "test-task-id" - mock_task_message.content = {"type": "text", "content": "test"} - - mock_streaming_context.task_message = mock_task_message - mock_streaming_context.stream_update = AsyncMock() - - # Record the initial_content passed to each opened streaming context. - # The unified harness auto_send path posts full tool messages by opening - # a context with initial_content and closing it (no stream_update), so - # assertions inspect the opened contents rather than stream_update calls. - opened_contents: list = [] - - # Create a proper async context manager mock - from contextlib import asynccontextmanager - from unittest.mock import AsyncMock - - @asynccontextmanager - async def mock_streaming_context_manager(*_args, **kwargs): - if "initial_content" in kwargs: - opened_contents.append(kwargs["initial_content"]) - yield mock_streaming_context - - mock_streaming_service.streaming_task_message_context = mock_streaming_context_manager - # Expose the recorded contents on the returned context mock for assertions. - mock_streaming_context.opened_contents = opened_contents - - openai_service.streaming_service = mock_streaming_service - openai_service.agentex_client = mock_agentex_client - - return mock_streaming_context - - def _setup_streaming_service_mocks_with_created_at(self, openai_service): - """Like _setup_streaming_service_mocks but also records every created_at kwarg.""" - from contextlib import asynccontextmanager - from unittest.mock import AsyncMock - - from agentex.types.task_message import TaskMessage - - mock_streaming_service = AsyncMock() - mock_agentex_client = AsyncMock() - - mock_streaming_context = AsyncMock() - mock_task_message = Mock(spec=TaskMessage) - mock_task_message.id = "test-task-message-id" - mock_task_message.task_id = "test-task-id" - mock_task_message.content = {"type": "text", "content": "test"} - mock_streaming_context.task_message = mock_task_message - mock_streaming_context.stream_update = AsyncMock() - - recorded_created_ats: list = [] - - @asynccontextmanager - async def mock_ctx_manager(*_args, **kwargs): - recorded_created_ats.append(kwargs.get("created_at")) - yield mock_streaming_context - - mock_streaming_service.streaming_task_message_context = mock_ctx_manager - mock_streaming_context.opened_contents = [] - - openai_service.streaming_service = mock_streaming_service - openai_service.agentex_client = mock_agentex_client - - return mock_streaming_context, recorded_created_ats - - def _create_code_interpreter_tool_call_mock(self, call_id="code_interpreter_call_123"): - """Helper to create ResponseCodeInterpreterToolCall mock objects.""" - return ResponseCodeInterpreterToolCall( - id=call_id, - type="code_interpreter_call", - status="completed", - code="print('Hello from code interpreter')", - container_id="container_123", - outputs=[], - ) - - def _create_tool_call_item_mock(self, tool_call): - """Helper to create tool call item mock.""" - mock_tool_call_item = Mock() - mock_tool_call_item.type = "tool_call_item" - mock_tool_call_item.raw_item = tool_call - return mock_tool_call_item - - def _create_tool_output_item_mock(self, call_id="code_interpreter_call_123", output="Hello from code interpreter"): - """Helper to create tool output item mock.""" - mock_tool_output_item = Mock() - mock_tool_output_item.type = "tool_call_output_item" - mock_tool_output_item.raw_item = {"call_id": call_id, "output": output} - return mock_tool_output_item - - def _create_streaming_result_mock(self, final_output="Code executed successfully"): - """Helper to create streaming result mock with common setup.""" - mock_streaming_result = Mock(spec=RunResultStreaming) - mock_streaming_result.final_output = final_output - mock_streaming_result.new_items = [] - # OpenAITurn reads raw_responses after stream exhaustion to aggregate - # usage; provide an empty list so usage normalizes to model-only. - mock_streaming_result.raw_responses = [] - mock_streaming_result.final_input_list = [ - {"role": "user", "content": "Run some Python code"}, - {"role": "assistant", "content": final_output}, - ] - mock_streaming_result.to_input_list.return_value = [ - {"role": "user", "content": "Run some Python code"}, - {"role": "assistant", "content": final_output}, - ] - return mock_streaming_result - - def _create_common_agent_params(self, **overrides): - """Helper to create common agent parameters with defaults.""" - defaults = { - "input_list": [{"role": "user", "content": "Run some Python code"}], - "mcp_server_params": [], - "agent_name": "test_agent", - "agent_instructions": "You are a helpful assistant with code interpreter", - "trace_id": "test-trace-id", - "parent_span_id": "test-span-id", - "task_id": "test-task-id", - } - defaults.update(overrides) - return defaults diff --git a/tests/lib/adk/providers/test_openai_turn.py b/tests/lib/adk/providers/test_openai_turn.py deleted file mode 100644 index d5ad2b5c8..000000000 --- a/tests/lib/adk/providers/test_openai_turn.py +++ /dev/null @@ -1,248 +0,0 @@ -"""Tests for OpenAITurn and its usage mapping. - -OpenAITurn adapts an OpenAI Agents SDK streamed run onto the harness -``HarnessTurn`` protocol. These tests cover: -- ``openai_usage_to_turn_usage`` (full usage, None, real zeros) -- ``_aggregate_usage`` (empty, single, multiple ModelResponses) -- ``OpenAITurn.events`` driven by an injected canonical stream (bypassing the - OpenAI->canonical converter), plus ``usage()`` before/after exhaustion -- the ``ValueError`` guard when neither ``result`` nor ``stream`` is supplied -""" - -import types as _types - -import pytest -from agents.usage import Usage -from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails - -from agentex.types.text_content import TextContent -from agentex.types.task_message_delta import TextDelta -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) - - -def _import_target(): - from agentex.lib.adk._modules._openai_turn import ( - OpenAITurn, - _aggregate_usage, - openai_usage_to_turn_usage, - ) - - return OpenAITurn, _aggregate_usage, openai_usage_to_turn_usage - - -# --------------------------------------------------------------------------- -# openai_usage_to_turn_usage -# --------------------------------------------------------------------------- - - -def test_usage_mapping_full(): - _, _, openai_usage_to_turn_usage = _import_target() - usage = Usage( - requests=3, - input_tokens=100, - input_tokens_details=InputTokensDetails(cached_tokens=20), - output_tokens=50, - output_tokens_details=OutputTokensDetails(reasoning_tokens=10), - total_tokens=150, - ) - turn_usage = openai_usage_to_turn_usage(usage, model="gpt-4o") - - assert turn_usage.model == "gpt-4o" - assert turn_usage.num_llm_calls == 3 - assert turn_usage.input_tokens == 100 - assert turn_usage.cached_input_tokens == 20 - assert turn_usage.output_tokens == 50 - assert turn_usage.reasoning_tokens == 10 - assert turn_usage.total_tokens == 150 - - -def test_usage_mapping_none_usage(): - _, _, openai_usage_to_turn_usage = _import_target() - turn_usage = openai_usage_to_turn_usage(None, model="gpt-4o") - - assert turn_usage.model == "gpt-4o" - # num_llm_calls is None ("not reported") when no usage is present, matching - # the token fields below; a real 0 is only reported when the provider says so. - assert turn_usage.num_llm_calls is None - assert turn_usage.input_tokens is None - assert turn_usage.output_tokens is None - assert turn_usage.total_tokens is None - - -def test_usage_mapping_real_zeros_are_preserved(): - # A cache hit can legitimately produce 0 output tokens; a present-but-zero - # value must survive as 0, not be coerced to None. - _, _, openai_usage_to_turn_usage = _import_target() - usage = Usage( - requests=1, - input_tokens=0, - input_tokens_details=InputTokensDetails(cached_tokens=0), - output_tokens=0, - output_tokens_details=OutputTokensDetails(reasoning_tokens=0), - total_tokens=0, - ) - turn_usage = openai_usage_to_turn_usage(usage, model="m") - - assert turn_usage.input_tokens == 0 - assert turn_usage.cached_input_tokens == 0 - assert turn_usage.output_tokens == 0 - assert turn_usage.reasoning_tokens == 0 - assert turn_usage.total_tokens == 0 - assert turn_usage.num_llm_calls == 1 - - -# --------------------------------------------------------------------------- -# _aggregate_usage -# --------------------------------------------------------------------------- - - -def _resp(usage): - return _types.SimpleNamespace(usage=usage) - - -def test_aggregate_usage_empty(): - _, _aggregate_usage, _ = _import_target() - assert _aggregate_usage([]) is None - - -def test_aggregate_usage_single(): - _, _aggregate_usage, _ = _import_target() - usage = Usage(requests=1, input_tokens=10, output_tokens=5, total_tokens=15) - total = _aggregate_usage([_resp(usage)]) - - assert total is not None - assert total.requests == 1 - assert total.input_tokens == 10 - assert total.output_tokens == 5 - assert total.total_tokens == 15 - - -def test_aggregate_usage_multiple(): - _, _aggregate_usage, _ = _import_target() - u1 = Usage( - requests=1, - input_tokens=10, - input_tokens_details=InputTokensDetails(cached_tokens=2), - output_tokens=5, - output_tokens_details=OutputTokensDetails(reasoning_tokens=1), - total_tokens=15, - ) - u2 = Usage( - requests=2, - input_tokens=20, - input_tokens_details=InputTokensDetails(cached_tokens=3), - output_tokens=7, - output_tokens_details=OutputTokensDetails(reasoning_tokens=4), - total_tokens=27, - ) - # A response without usage must be skipped, not crash the aggregation. - total = _aggregate_usage([_resp(u1), _resp(None), _resp(u2)]) - - assert total is not None - assert total.requests == 3 - assert total.input_tokens == 30 - assert total.output_tokens == 12 - assert total.total_tokens == 42 - assert total.input_tokens_details.cached_tokens == 5 - assert total.output_tokens_details.reasoning_tokens == 5 - - -# --------------------------------------------------------------------------- -# OpenAITurn.events / usage / construction -# --------------------------------------------------------------------------- - - -async def _canonical_stream(events): - for e in events: - yield e - - -@pytest.mark.asyncio -async def test_turn_events_forwards_injected_stream(): - OpenAITurn, _, _ = _import_target() - events = [ - StreamTaskMessageStart(type="start", index=0, content=TextContent(type="text", author="agent", content="")), - StreamTaskMessageDelta(type="delta", index=0, delta=TextDelta(type="text", text_delta="Hi")), - StreamTaskMessageDone(type="done", index=0), - ] - turn = OpenAITurn(stream=_canonical_stream(events), model="gpt-4o") - - out = [e async for e in turn.events] - assert out == events - - -@pytest.mark.asyncio -async def test_turn_usage_before_and_after_exhaustion_with_injected_stream(): - OpenAITurn, _, _ = _import_target() - events = [ - StreamTaskMessageStart(type="start", index=0, content=TextContent(type="text", author="agent", content="")), - StreamTaskMessageDone(type="done", index=0), - ] - turn = OpenAITurn(stream=_canonical_stream(events), model="gpt-4o") - - # Before exhaustion: usage carries only the model name. - before = turn.usage() - assert before.model == "gpt-4o" - assert before.input_tokens is None - - async for _ in turn.events: - pass - - # With an injected stream there is no run to read usage from, so usage - # stays model-only after exhaustion. - after = turn.usage() - assert after.model == "gpt-4o" - assert after.input_tokens is None - - -@pytest.mark.asyncio -async def test_turn_usage_populated_from_result_after_exhaustion(): - OpenAITurn, _, _ = _import_target() - - canonical = [ - StreamTaskMessageStart(type="start", index=0, content=TextContent(type="text", author="agent", content="")), - StreamTaskMessageDone(type="done", index=0), - ] - - class _FakeResult: - def __init__(self): - self.raw_responses = [ - _resp(Usage(requests=1, input_tokens=8, output_tokens=4, total_tokens=12)), - ] - - def stream_events(self): - # OpenAITurn passes this to convert_openai_to_agentex_events; we - # monkeypatch that converter below so this can yield canonical events. - return _canonical_stream(canonical) - - import agentex.lib.adk._modules._openai_turn as mod - - async def _passthrough(stream): - async for e in stream: - yield e - - original = mod.convert_openai_to_agentex_events - mod.convert_openai_to_agentex_events = _passthrough - try: - turn = OpenAITurn(result=_FakeResult(), model="gpt-4o") - out = [e async for e in turn.events] - finally: - mod.convert_openai_to_agentex_events = original - - assert out == canonical - usage = turn.usage() - assert usage.model == "gpt-4o" - assert usage.num_llm_calls == 1 - assert usage.input_tokens == 8 - assert usage.output_tokens == 4 - assert usage.total_tokens == 12 - - -def test_turn_requires_result_or_stream(): - OpenAITurn, _, _ = _import_target() - with pytest.raises(ValueError, match="either"): - OpenAITurn() diff --git a/tests/lib/adk/test_claude_code_sync.py b/tests/lib/adk/test_claude_code_sync.py deleted file mode 100644 index 5a78acaf7..000000000 --- a/tests/lib/adk/test_claude_code_sync.py +++ /dev/null @@ -1,715 +0,0 @@ -"""Tests for the claude-code stream-json -> Agentex StreamTaskMessage* converter.""" - -from __future__ import annotations - -from typing import Any, AsyncIterator - -import pytest - -from agentex.types.text_content import TextContent -from agentex.types.reasoning_content import ReasoningContent -from agentex.types.task_message_delta import TextDelta -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.types.reasoning_content_delta import ReasoningContentDelta -from agentex.lib.adk._modules._claude_code_sync import convert_claude_code_to_agentex_events - - -async def _aiter(events: list[Any]) -> AsyncIterator[Any]: - for e in events: - yield e - - -async def _collect(stream: AsyncIterator[Any]) -> list[Any]: - return [e async for e in stream] - - -# --------------------------------------------------------------------------- -# Text content -# --------------------------------------------------------------------------- - - -class TestTextContent: - async def test_text_block_in_assistant_message_emits_start_delta_done(self): - envelopes = [ - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "Hello world"}]}, - } - ] - out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) - - assert len(out) == 3 - assert isinstance(out[0], StreamTaskMessageStart) - assert isinstance(out[0].content, TextContent) - assert out[0].content.content == "" - assert isinstance(out[1], StreamTaskMessageDelta) - assert isinstance(out[1].delta, TextDelta) - assert out[1].delta.text_delta == "Hello world" - assert isinstance(out[2], StreamTaskMessageDone) - assert out[0].index == out[1].index == out[2].index - - async def test_empty_text_block_is_skipped(self): - envelopes = [ - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": ""}]}, - } - ] - out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) - assert out == [] - - async def test_streamed_text_via_stream_event_emits_start_deltas_done(self): - envelopes = [ - { - "type": "stream_event", - "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "text"}}, - }, - { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 0, - "delta": {"type": "text_delta", "text": "Hello"}, - }, - }, - { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 0, - "delta": {"type": "text_delta", "text": " world"}, - }, - }, - { - "type": "stream_event", - "event": {"type": "content_block_stop", "index": 0}, - }, - ] - out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) - - starts = [e for e in out if isinstance(e, StreamTaskMessageStart)] - deltas = [e for e in out if isinstance(e, StreamTaskMessageDelta)] - dones = [e for e in out if isinstance(e, StreamTaskMessageDone)] - - assert len(starts) == 1 - assert isinstance(starts[0].content, TextContent) - assert len(deltas) == 2 - assert isinstance(deltas[0].delta, TextDelta) - assert deltas[0].delta.text_delta == "Hello" - assert isinstance(deltas[1].delta, TextDelta) - assert deltas[1].delta.text_delta == " world" - assert len(dones) == 1 - - async def test_streamed_text_not_re_emitted_by_assistant_block(self): - """After stream_event triple, the final assistant block must not re-emit the text.""" - envelopes = [ - { - "type": "stream_event", - "event": { - "type": "content_block_start", - "index": 0, - "content_block": {"type": "text"}, - }, - }, - { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 0, - "delta": {"type": "text_delta", "text": "streamed"}, - }, - }, - { - "type": "stream_event", - "event": {"type": "content_block_stop", "index": 0}, - }, - # Final assistant message with same text โ€” must NOT be re-emitted - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "streamed"}]}, - }, - ] - out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) - text_starts = [e for e in out if isinstance(e, StreamTaskMessageStart) and isinstance(e.content, TextContent)] - assert len(text_starts) == 1, "Text block must not be emitted twice" - - async def test_streamed_message_split_across_assistant_envelopes_not_duplicated(self): - """Regression: one streamed message (thinking + text) can materialise as - SEPARATE assistant envelopes. Content-based dedup must skip both streamed - blocks even though the text arrives in its own later envelope โ€” an earlier - index-based scheme re-emitted the text (duplicate).""" - envelopes = [ - # Streamed: thinking at block index 0, then text at block index 1. - { - "type": "stream_event", - "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "thinking"}}, - }, - { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 0, - "delta": {"type": "thinking_delta", "thinking": "ponder"}, - }, - }, - {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}}, - { - "type": "stream_event", - "event": {"type": "content_block_start", "index": 1, "content_block": {"type": "text"}}, - }, - { - "type": "stream_event", - "event": {"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "answer"}}, - }, - {"type": "stream_event", "event": {"type": "content_block_stop", "index": 1}}, - # Materialised as two separate assistant envelopes (thinking alone at - # idx 0, then text alone at idx 0) โ€” the shape that caused duplicates. - {"type": "assistant", "message": {"content": [{"type": "thinking", "thinking": "ponder"}]}}, - {"type": "assistant", "message": {"content": [{"type": "text", "text": "answer"}]}}, - ] - out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) - text_starts = [e for e in out if isinstance(e, StreamTaskMessageStart) and isinstance(e.content, TextContent)] - reasoning_starts = [ - e for e in out if isinstance(e, StreamTaskMessageStart) and isinstance(e.content, ReasoningContent) - ] - assert len(text_starts) == 1, "Streamed text must not be re-emitted by its own materialised envelope" - assert len(reasoning_starts) == 1, "Streamed thinking must not be re-emitted either" - - async def test_interleaved_materialized_block_not_duplicated(self): - """Regression: the materialised `assistant` envelope can arrive MID-stream - (before the streamed block's content_block_stop). Content-recorded dedup - hasn't fired yet, so the still-open block's partial buffer is prefix-matched - against the materialised full text to suppress the duplicate.""" - envelopes = [ - { - "type": "stream_event", - "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "thinking"}}, - }, - { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 0, - "delta": {"type": "thinking_delta", "thinking": "I"}, - }, - }, - # Materialised envelope interleaved before content_block_stop. - {"type": "assistant", "message": {"content": [{"type": "thinking", "thinking": "I need to load tools."}]}}, - { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 0, - "delta": {"type": "thinking_delta", "thinking": " need to load tools."}, - }, - }, - {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}}, - ] - out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) - reasoning_starts = [ - e for e in out if isinstance(e, StreamTaskMessageStart) and isinstance(e.content, ReasoningContent) - ] - assert len(reasoning_starts) == 1, "Interleaved materialised reasoning must not duplicate the streamed block" - - async def test_later_turn_non_streamed_text_not_dropped(self): - """A non-streamed text block in a later turn must not be dropped because an - earlier turn streamed a block at the same index.""" - envelopes = [ - # Turn 1: streamed text at index 0 (dedup'd against the materialised msg). - { - "type": "stream_event", - "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "text"}}, - }, - { - "type": "stream_event", - "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "first"}}, - }, - {"type": "stream_event", "event": {"type": "content_block_stop", "index": 0}}, - {"type": "assistant", "message": {"content": [{"type": "text", "text": "first"}]}}, - # Turn 2: a NON-streamed text block, also at index 0. - {"type": "assistant", "message": {"content": [{"type": "text", "text": "second"}]}}, - ] - out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) - deltas = [ - e.delta.text_delta for e in out if isinstance(e, StreamTaskMessageDelta) and isinstance(e.delta, TextDelta) - ] - assert deltas == ["first", "second"], "Later turn's non-streamed text must still be delivered" - - -# --------------------------------------------------------------------------- -# Thinking / reasoning content -# --------------------------------------------------------------------------- - - -class TestThinkingContent: - async def test_thinking_block_emits_reasoning_start_delta_done(self): - envelopes = [ - { - "type": "assistant", - "message": {"content": [{"type": "thinking", "thinking": "Let me reason..."}]}, - } - ] - out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) - - assert len(out) == 3 - assert isinstance(out[0], StreamTaskMessageStart) - assert isinstance(out[0].content, ReasoningContent) - # Summary must be populated from the thinking text - assert out[0].content.summary == ["Let me reason..."] - assert isinstance(out[1], StreamTaskMessageDelta) - assert isinstance(out[1].delta, ReasoningContentDelta) - assert out[1].delta.content_delta == "Let me reason..." - assert out[1].delta.content_index == 0 - assert isinstance(out[2], StreamTaskMessageDone) - - async def test_empty_thinking_block_is_skipped(self): - envelopes = [ - { - "type": "assistant", - "message": {"content": [{"type": "thinking", "thinking": ""}]}, - } - ] - out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) - assert out == [] - - async def test_streamed_thinking_emits_reasoning_start_deltas_done(self): - envelopes = [ - { - "type": "stream_event", - "event": { - "type": "content_block_start", - "index": 0, - "content_block": {"type": "thinking"}, - }, - }, - { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 0, - "delta": {"type": "thinking_delta", "thinking": "step one"}, - }, - }, - { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 0, - "delta": {"type": "thinking_delta", "thinking": " step two"}, - }, - }, - { - "type": "stream_event", - "event": {"type": "content_block_stop", "index": 0}, - }, - ] - out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) - - starts = [e for e in out if isinstance(e, StreamTaskMessageStart)] - deltas = [e for e in out if isinstance(e, StreamTaskMessageDelta)] - dones = [e for e in out if isinstance(e, StreamTaskMessageDone)] - - assert len(starts) == 1 - assert isinstance(starts[0].content, ReasoningContent) - assert len(deltas) == 2 - assert isinstance(deltas[0].delta, ReasoningContentDelta) - assert deltas[0].delta.content_delta == "step one" - assert isinstance(deltas[1].delta, ReasoningContentDelta) - assert deltas[1].delta.content_delta == " step two" - assert len(dones) == 1 - - async def test_two_streamed_thinking_blocks_not_re_emitted(self): - """A turn that streams two thinking blocks must claim both indices, so the - final assistant envelope does not re-emit the second one.""" - - def _thinking_block(idx: int, text: str) -> list: - return [ - { - "type": "stream_event", - "event": {"type": "content_block_start", "index": idx, "content_block": {"type": "thinking"}}, - }, - { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": idx, - "delta": {"type": "thinking_delta", "thinking": text}, - }, - }, - {"type": "stream_event", "event": {"type": "content_block_stop", "index": idx}}, - ] - - envelopes = [ - *_thinking_block(0, "first thought"), - *_thinking_block(1, "second thought"), - # Final assistant envelope repeats both thinking blocks โ€” neither should re-emit. - { - "type": "assistant", - "message": { - "content": [ - {"type": "thinking", "thinking": "first thought"}, - {"type": "thinking", "thinking": "second thought"}, - ] - }, - }, - ] - out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) - reasoning_starts = [ - e for e in out if isinstance(e, StreamTaskMessageStart) and isinstance(e.content, ReasoningContent) - ] - assert len(reasoning_starts) == 2, "each streamed thinking block emitted exactly once (no duplicate)" - - async def test_thinking_block_start_with_no_deltas_allows_assistant_to_fill(self): - """A thinking block_start without any deltas leaves the final assistant block - free to emit the thinking text (the block index is not claimed as streamed).""" - envelopes = [ - { - "type": "stream_event", - "event": { - "type": "content_block_start", - "index": 0, - "content_block": {"type": "thinking"}, - }, - }, - # No thinking_delta โ€” close block immediately - { - "type": "stream_event", - "event": {"type": "content_block_stop", "index": 0}, - }, - # Final assistant message has the thinking text - { - "type": "assistant", - "message": {"content": [{"type": "thinking", "thinking": "delayed thinking"}]}, - }, - ] - out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) - # The assistant block should produce a full thinking message (Start+Delta+Done) - reasoning_starts = [ - e for e in out if isinstance(e, StreamTaskMessageStart) and isinstance(e.content, ReasoningContent) - ] - # There will be the empty start from stream_event, plus the one from assistant block - reasoning_deltas = [ - e for e in out if isinstance(e, StreamTaskMessageDelta) and isinstance(e.delta, ReasoningContentDelta) - ] - assert len(reasoning_deltas) >= 1 - assert any( - isinstance(d.delta, ReasoningContentDelta) and d.delta.content_delta == "delayed thinking" - for d in reasoning_deltas - ) - - -# --------------------------------------------------------------------------- -# Tool calls and results -# --------------------------------------------------------------------------- - - -class TestToolCallsAndResults: - async def test_tool_use_block_emits_start_done(self): - envelopes = [ - { - "type": "assistant", - "message": { - "content": [ - { - "type": "tool_use", - "id": "call_abc", - "name": "Bash", - "input": {"command": "ls /"}, - } - ] - }, - } - ] - out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) - - assert len(out) == 2 - assert isinstance(out[0], StreamTaskMessageStart) - assert isinstance(out[0].content, ToolRequestContent) - assert out[0].content.tool_call_id == "call_abc" - assert out[0].content.name == "Bash" - assert out[0].content.arguments == {"command": "ls /"} - assert isinstance(out[1], StreamTaskMessageDone) - - async def test_tool_result_block_emits_full(self): - envelopes = [ - { - "type": "user", - "message": { - "content": [ - { - "type": "tool_result", - "tool_use_id": "call_abc", - "content": "file1.txt\nfile2.txt", - } - ] - }, - } - ] - out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) - - assert len(out) == 1 - assert isinstance(out[0], StreamTaskMessageFull) - assert isinstance(out[0].content, ToolResponseContent) - assert out[0].content.tool_call_id == "call_abc" - assert "file1.txt" in str(out[0].content.content) - - async def test_tool_result_list_content_joined(self): - envelopes = [ - { - "type": "user", - "message": { - "content": [ - { - "type": "tool_result", - "tool_use_id": "tid", - "content": [ - {"type": "text", "text": "line1"}, - {"type": "text", "text": "line2"}, - ], - } - ] - }, - } - ] - out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) - assert isinstance(out[0], StreamTaskMessageFull) - assert isinstance(out[0].content, ToolResponseContent) - payload = str(out[0].content.content) - assert "line1" in payload - assert "line2" in payload - - async def test_tool_result_error_flag_passed_through(self): - envelopes = [ - { - "type": "user", - "message": { - "content": [ - { - "type": "tool_result", - "tool_use_id": "err_call", - "content": "Permission denied", - "is_error": True, - } - ] - }, - } - ] - out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) - assert isinstance(out[0], StreamTaskMessageFull) - assert isinstance(out[0].content, ToolResponseContent) - assert isinstance(out[0].content.content, dict) - assert out[0].content.content.get("is_error") is True - - async def test_tool_result_truncation(self): - long_result = "x" * 5000 - envelopes = [ - { - "type": "user", - "message": { - "content": [ - { - "type": "tool_result", - "tool_use_id": "t", - "content": long_result, - } - ] - }, - } - ] - out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) - result_str = out[0].content.content.get("result", "") - assert len(result_str) <= 4000 - - -# --------------------------------------------------------------------------- -# on_result callback -# --------------------------------------------------------------------------- - - -class TestOnResult: - async def test_on_result_called_with_result_envelope(self): - captured: list[dict] = [] - - async def capture(envelope): - captured.append(envelope) - - envelopes = [ - { - "type": "result", - "session_id": "sess123", - "cost_usd": 0.012, - "usage": {"input_tokens": 100, "output_tokens": 50}, - } - ] - out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes), on_result=capture)) - - # result envelope does not emit any StreamTaskMessage - assert out == [] - assert len(captured) == 1 - assert captured[0]["session_id"] == "sess123" - assert captured[0]["cost_usd"] == pytest.approx(0.012) - - async def test_on_result_not_called_when_no_result_envelope(self): - captured: list[dict] = [] - - async def capture(envelope): - captured.append(envelope) - - envelopes = [ - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "Hi"}]}, - } - ] - await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes), on_result=capture)) - assert captured == [] - - async def test_no_on_result_does_not_raise(self): - envelopes = [ - { - "type": "result", - "cost_usd": 0.001, - "usage": {"input_tokens": 10, "output_tokens": 5}, - } - ] - # Should not raise even without a callback - out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) - assert out == [] - - -# --------------------------------------------------------------------------- -# Message indexing -# --------------------------------------------------------------------------- - - -class TestMessageIndexing: - async def test_multiple_blocks_get_distinct_indices(self): - envelopes = [ - { - "type": "assistant", - "message": { - "content": [ - {"type": "text", "text": "First"}, - { - "type": "tool_use", - "id": "c1", - "name": "Read", - "input": {"path": "/tmp"}, - }, - ] - }, - }, - { - "type": "user", - "message": { - "content": [ - { - "type": "tool_result", - "tool_use_id": "c1", - "content": "some content", - } - ] - }, - }, - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "Done"}]}, - }, - ] - out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) - - # Gather all Start/Full events and check indices are monotonically increasing - anchors = [e for e in out if isinstance(e, (StreamTaskMessageStart, StreamTaskMessageFull))] - indices = [e.index for e in anchors] - assert indices == sorted(indices), "Indices must be monotonically increasing" - assert len(set(indices)) == len(indices), "All indices must be distinct" - - async def test_system_init_and_unknown_envelopes_produce_no_output(self): - envelopes = [ - {"type": "system", "subtype": "init", "session_id": "sess"}, - {"type": "unknown_future_type", "data": "whatever"}, - ] - out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes))) - assert out == [] - - async def test_non_json_string_lines_are_skipped(self): - lines = [ - "not json at all", - '{"type": "assistant", "message": {"content": [{"type": "text", "text": "hi"}]}}', - ] - - async def _str_iter(): - for line in lines: - yield line - - out = await _collect(convert_claude_code_to_agentex_events(_str_iter())) - assert len(out) == 3 # Start + Delta + Done for the text block - - async def test_empty_lines_are_skipped(self): - lines = ["", " ", '{"type": "system", "subtype": "init"}'] - - async def _str_iter(): - for line in lines: - yield line - - out = await _collect(convert_claude_code_to_agentex_events(_str_iter())) - assert out == [] - - -# --------------------------------------------------------------------------- -# Author -# --------------------------------------------------------------------------- - - -class TestContentAuthors: - @pytest.mark.parametrize( - "envelope", - [ - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "hi"}]}, - }, - { - "type": "assistant", - "message": {"content": [{"type": "thinking", "thinking": "thoughts"}]}, - }, - { - "type": "assistant", - "message": { - "content": [ - { - "type": "tool_use", - "id": "c", - "name": "t", - "input": {}, - } - ] - }, - }, - { - "type": "user", - "message": { - "content": [ - { - "type": "tool_result", - "tool_use_id": "c", - "content": "ok", - } - ] - }, - }, - ], - ) - async def test_all_content_authored_by_agent(self, envelope: dict): - out = await _collect(convert_claude_code_to_agentex_events(_aiter([envelope]))) - for e in out: - content = getattr(e, "content", None) - if content is not None and hasattr(content, "author"): - assert content.author == "agent" diff --git a/tests/lib/adk/test_claude_code_turn.py b/tests/lib/adk/test_claude_code_turn.py deleted file mode 100644 index be80dfe1f..000000000 --- a/tests/lib/adk/test_claude_code_turn.py +++ /dev/null @@ -1,351 +0,0 @@ -"""Tests for ClaudeCodeTurn and claude_code_usage_to_turn_usage.""" - -from __future__ import annotations - -from typing import Any, AsyncIterator - -import pytest - -from agentex.lib.core.harness.types import TurnUsage, HarnessTurn -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.types.tool_response_content import ToolResponseContent -from agentex.lib.adk._modules._claude_code_turn import ( - ClaudeCodeTurn, - claude_code_usage_to_turn_usage, -) - - -async def _aiter(events: list[Any]) -> AsyncIterator[Any]: - for e in events: - yield e - - -async def _drain(turn: ClaudeCodeTurn) -> list[Any]: - return [e async for e in turn.events] - - -# --------------------------------------------------------------------------- -# Usage normalization -# --------------------------------------------------------------------------- - - -class TestClaudeCodeUsageToTurnUsage: - def test_full_usage_fields(self): - result = { - "usage": { - "input_tokens": 100, - "output_tokens": 50, - "cache_read_input_tokens": 20, - "cache_creation_input_tokens": 5, - }, - "cost_usd": 0.025, - "duration_ms": 3200, - "num_turns": 3, - } - usage = claude_code_usage_to_turn_usage(result) - - assert usage.input_tokens == 100 - assert usage.output_tokens == 50 - assert usage.cached_input_tokens == 25 # 20 + 5 - assert usage.total_tokens == 150 - assert usage.cost_usd == pytest.approx(0.025) - assert usage.duration_ms == 3200 - assert usage.num_llm_calls == 3 - - def test_total_cost_usd_fallback(self): - """total_cost_usd should be used when cost_usd is absent.""" - result = { - "usage": {"input_tokens": 10, "output_tokens": 5}, - "total_cost_usd": 0.001, - } - usage = claude_code_usage_to_turn_usage(result) - assert usage.cost_usd == pytest.approx(0.001) - - def test_cost_usd_takes_precedence_over_total_cost_usd(self): - result = { - "usage": {"input_tokens": 10, "output_tokens": 5}, - "cost_usd": 0.002, - "total_cost_usd": 0.999, - } - usage = claude_code_usage_to_turn_usage(result) - assert usage.cost_usd == pytest.approx(0.002) - - def test_missing_usage_key_returns_nones(self): - result: dict[str, Any] = {} - usage = claude_code_usage_to_turn_usage(result) - assert usage.input_tokens is None - assert usage.output_tokens is None - assert usage.cached_input_tokens is None - assert usage.total_tokens is None - assert usage.cost_usd is None - assert usage.duration_ms is None - assert usage.num_llm_calls is None - - def test_real_zeros_preserved(self): - result = { - "usage": { - "input_tokens": 0, - "output_tokens": 0, - "cache_read_input_tokens": 0, - "cache_creation_input_tokens": 0, - }, - "cost_usd": 0.0, - "duration_ms": 0, - "num_turns": 0, - } - usage = claude_code_usage_to_turn_usage(result) - assert usage.input_tokens == 0 - assert usage.output_tokens == 0 - assert usage.cached_input_tokens == 0 - assert usage.total_tokens == 0 - assert usage.cost_usd == pytest.approx(0.0) - assert usage.duration_ms == 0 - assert usage.num_llm_calls == 0 - - def test_only_cache_read_no_creation(self): - result = { - "usage": { - "input_tokens": 50, - "output_tokens": 25, - "cache_read_input_tokens": 15, - } - } - usage = claude_code_usage_to_turn_usage(result) - assert usage.cached_input_tokens == 15 - - def test_only_cache_creation_no_read(self): - result = { - "usage": { - "input_tokens": 50, - "output_tokens": 25, - "cache_creation_input_tokens": 10, - } - } - usage = claude_code_usage_to_turn_usage(result) - assert usage.cached_input_tokens == 10 - - def test_no_cache_fields_gives_none(self): - result = {"usage": {"input_tokens": 10, "output_tokens": 5}} - usage = claude_code_usage_to_turn_usage(result) - assert usage.cached_input_tokens is None - - def test_total_tokens_computed_from_input_output(self): - result = {"usage": {"input_tokens": 70, "output_tokens": 30}} - usage = claude_code_usage_to_turn_usage(result) - assert usage.total_tokens == 100 - - def test_missing_output_tokens_leaves_total_none(self): - result = {"usage": {"input_tokens": 70}} - usage = claude_code_usage_to_turn_usage(result) - assert usage.total_tokens is None - - def test_returns_turn_usage_instance(self): - result = {"usage": {"input_tokens": 1, "output_tokens": 1}} - usage = claude_code_usage_to_turn_usage(result) - assert isinstance(usage, TurnUsage) - - -# --------------------------------------------------------------------------- -# ClaudeCodeTurn protocol -# --------------------------------------------------------------------------- - - -class TestClaudeCodeTurnProtocol: - def test_satisfies_harness_turn_protocol(self): - """ClaudeCodeTurn must satisfy the HarnessTurn structural protocol.""" - turn = ClaudeCodeTurn(_aiter([])) - assert isinstance(turn, HarnessTurn) - - async def test_events_yields_stream_task_messages(self): - envelopes = [ - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "Hi there"}]}, - } - ] - turn = ClaudeCodeTurn(_aiter(envelopes)) - out = await _drain(turn) - assert len(out) == 3 - assert isinstance(out[0], StreamTaskMessageStart) - assert isinstance(out[1], StreamTaskMessageDelta) - assert isinstance(out[2], StreamTaskMessageDone) - - async def test_usage_before_drain_returns_empty(self): - envelopes = [ - { - "type": "result", - "usage": {"input_tokens": 100, "output_tokens": 50}, - "cost_usd": 0.01, - } - ] - turn = ClaudeCodeTurn(_aiter(envelopes)) - # usage() called before events drained โ€” no result envelope yet - usage = turn.usage() - assert isinstance(usage, TurnUsage) - assert usage.input_tokens is None - - async def test_usage_after_drain_reflects_result(self): - envelopes = [ - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "response"}]}, - }, - { - "type": "result", - "usage": {"input_tokens": 200, "output_tokens": 80}, - "cost_usd": 0.015, - "num_turns": 2, - }, - ] - turn = ClaudeCodeTurn(_aiter(envelopes)) - await _drain(turn) - usage = turn.usage() - - assert usage.input_tokens == 200 - assert usage.output_tokens == 80 - assert usage.cost_usd == pytest.approx(0.015) - assert usage.num_llm_calls == 2 - - async def test_usage_empty_when_no_result_envelope(self): - envelopes = [ - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "no result"}]}, - } - ] - turn = ClaudeCodeTurn(_aiter(envelopes)) - await _drain(turn) - usage = turn.usage() - assert usage.input_tokens is None - assert usage.cost_usd is None - - async def test_tool_call_and_result_round_trip(self): - envelopes = [ - { - "type": "assistant", - "message": { - "content": [ - { - "type": "tool_use", - "id": "call_1", - "name": "Read", - "input": {"path": "/etc/hosts"}, - } - ] - }, - }, - { - "type": "user", - "message": { - "content": [ - { - "type": "tool_result", - "tool_use_id": "call_1", - "content": "127.0.0.1 localhost", - } - ] - }, - }, - { - "type": "result", - "usage": {"input_tokens": 50, "output_tokens": 20}, - "cost_usd": 0.005, - }, - ] - turn = ClaudeCodeTurn(_aiter(envelopes)) - out = await _drain(turn) - usage = turn.usage() - - tool_starts = [ - e for e in out if isinstance(e, StreamTaskMessageStart) and isinstance(e.content, ToolResponseContent) - ] - tool_fulls = [ - e for e in out if isinstance(e, StreamTaskMessageFull) and isinstance(e.content, ToolResponseContent) - ] - assert len(tool_fulls) == 1 - full_content = tool_fulls[0].content - assert isinstance(full_content, ToolResponseContent) - assert full_content.tool_call_id == "call_1" - - assert usage.input_tokens == 50 - assert usage.output_tokens == 20 - - async def test_events_property_returns_same_iterator(self): - """Accessing .events multiple times returns the same iterator (not a new one each call).""" - turn = ClaudeCodeTurn(_aiter([])) - it1 = turn.events - it2 = turn.events - assert it1 is it2 - - -# --------------------------------------------------------------------------- -# Early session_id capture (resume after interrupt) -# --------------------------------------------------------------------------- - - -class TestClaudeCodeTurnSessionIdCapture: - async def test_session_id_from_result_when_turn_completes(self): - envelopes = [ - {"type": "system", "subtype": "init", "session_id": "sess-init"}, - {"type": "assistant", "message": {"content": [{"type": "text", "text": "hi"}]}}, - {"type": "result", "session_id": "sess-final", "usage": {}}, - ] - turn = ClaudeCodeTurn(_aiter(envelopes)) - await _drain(turn) - # Terminal result wins for a fully-completed turn. - assert turn.session_id == "sess-final" - - async def test_session_id_from_init_when_interrupted_before_result(self): - """No `result` envelope (turn cut short) must still yield a session_id. - - This is the resume fix: capture session_id from the early system/init - envelope so an interrupted-before-completion turn stays resumable. - """ - envelopes = [ - {"type": "system", "subtype": "init", "session_id": "sess-init"}, - {"type": "assistant", "message": {"content": [{"type": "text", "text": "partial"}]}}, - # stream ends here (interrupted) โ€” no result envelope - ] - turn = ClaudeCodeTurn(_aiter(envelopes)) - await _drain(turn) - assert turn.session_id == "sess-init" - - async def test_session_id_none_when_no_init_or_result(self): - envelopes = [ - {"type": "assistant", "message": {"content": [{"type": "text", "text": "x"}]}}, - ] - turn = ClaudeCodeTurn(_aiter(envelopes)) - await _drain(turn) - assert turn.session_id is None - - async def test_guarded_lines_closes_source_on_early_break(self): - """The converter's finally-backstop closes the source stdout iterator. - - Breaking out of the event loop early (as a cancellation/interrupt would) - must trigger aclose() on the underlying lines iterator so the CLI stdout - handle is not leaked. - """ - closed = {"value": False} - - async def _lines(): - try: - yield {"type": "system", "subtype": "init", "session_id": "sess-init"} - yield {"type": "assistant", "message": {"content": [{"type": "text", "text": "a"}]}} - yield {"type": "assistant", "message": {"content": [{"type": "text", "text": "b"}]}} - finally: - closed["value"] = True - - turn = ClaudeCodeTurn(_lines()) - events = turn.events - # Consume only the first event, then close the stream early. - async for _ in events: - break - events_aclose = getattr(events, "aclose", None) - assert events_aclose is not None - await events_aclose() - assert closed["value"] is True diff --git a/tests/lib/adk/test_codex_sync.py b/tests/lib/adk/test_codex_sync.py deleted file mode 100644 index 644688dfb..000000000 --- a/tests/lib/adk/test_codex_sync.py +++ /dev/null @@ -1,720 +0,0 @@ -"""Offline tests for the codex event-stream parser tap. - -Tests cover: -- Text streaming (agent_message items) -- Tool call streaming (command_execution, mcp_tool_call, file_change) -- Reasoning streaming (reasoning items) -- Multi-step turns -- Error events (top-level + item-level) -- Edge cases: empty events, non-JSON lines, unknown types -- on_result callback (session_id, usage, counters) -- file_change synthesized start (no item.started emitted by codex) -""" - -from __future__ import annotations - -import json -from typing import Any, AsyncIterator - -from agentex.types.reasoning_content import ReasoningContent -from agentex.types.task_message_delta import TextDelta -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.types.task_message_content import TextContent -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.lib.adk._modules._codex_sync import ( - _truncate, - _tool_args_for, - _tool_name_for, - _tool_output_for, - convert_codex_to_agentex_events, -) -from agentex.types.reasoning_content_delta import ReasoningContentDelta -from agentex.types.reasoning_summary_delta import ReasoningSummaryDelta - - -async def _aiter(items: list[Any]) -> AsyncIterator[Any]: - for item in items: - yield item - - -async def _collect(stream: AsyncIterator[Any]) -> list[Any]: - return [e async for e in stream] - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -class TestHelpers: - def test_truncate_short(self) -> None: - assert _truncate("hello", max_len=10) == "hello" - - def test_truncate_long(self) -> None: - assert _truncate("a" * 5000) == "a" * 4000 - - def test_tool_name_command_execution(self) -> None: - assert _tool_name_for("command_execution", {}) == "bash" - - def test_tool_name_file_change(self) -> None: - assert _tool_name_for("file_change", {}) == "file_change" - - def test_tool_name_mcp_with_server_and_tool(self) -> None: - assert _tool_name_for("mcp_tool_call", {"server": "fs", "tool": "read"}) == "fs.read" - - def test_tool_name_mcp_empty(self) -> None: - assert _tool_name_for("mcp_tool_call", {}) == "mcp_tool_call" - - def test_tool_name_unknown(self) -> None: - assert _tool_name_for("", {}) == "unknown" - - def test_tool_args_command(self) -> None: - assert _tool_args_for("command_execution", {"command": "ls"}) == {"command": "ls"} - - def test_tool_args_file_change(self) -> None: - assert _tool_args_for("file_change", {"changes": ["a"]}) == {"changes": ["a"]} - - def test_tool_args_mcp_dict(self) -> None: - assert _tool_args_for("mcp_tool_call", {"arguments": {"k": "v"}}) == {"k": "v"} - - def test_tool_args_mcp_non_dict(self) -> None: - assert _tool_args_for("mcp_tool_call", {"arguments": "str"}) == {"value": "str"} - - def test_tool_output_command_success(self) -> None: - text, is_err = _tool_output_for("command_execution", {"aggregated_output": "hello", "exit_code": 0}) - assert text == "hello" - assert is_err is False - - def test_tool_output_command_error(self) -> None: - _, is_err = _tool_output_for("command_execution", {"aggregated_output": "boom", "exit_code": 1}) - assert is_err is True - - def test_tool_output_mcp_error(self) -> None: - text, is_err = _tool_output_for("mcp_tool_call", {"error": {"message": "not found"}}) - assert "not found" in text - assert is_err is True - - def test_tool_output_mcp_result(self) -> None: - text, is_err = _tool_output_for("mcp_tool_call", {"result": {"data": 1}}) - assert json.loads(text) == {"data": 1} - assert is_err is False - - def test_tool_output_file_change_failed(self) -> None: - _, is_err = _tool_output_for("file_change", {"status": "failed", "changes": []}) - assert is_err is True - - def test_tool_output_file_change_ok(self) -> None: - text, is_err = _tool_output_for("file_change", {"status": "ok", "changes": [1, 2]}) - assert "2 changes" in text - assert is_err is False - - -# --------------------------------------------------------------------------- -# Text streaming -# --------------------------------------------------------------------------- - - -class TestTextStreaming: - async def test_text_start_delta_done(self) -> None: - events = [ - {"type": "item.started", "item": {"id": "m1", "type": "agent_message", "text": "Hi"}}, - {"type": "item.updated", "item": {"id": "m1", "type": "agent_message", "text": "Hi!"}}, - {"type": "item.completed", "item": {"id": "m1", "type": "agent_message", "text": "Hi! Done"}}, - ] - out = await _collect(convert_codex_to_agentex_events(_aiter(events))) - - starts = [e for e in out if isinstance(e, StreamTaskMessageStart)] - deltas = [e for e in out if isinstance(e, StreamTaskMessageDelta)] - dones = [e for e in out if isinstance(e, StreamTaskMessageDone)] - - assert len(starts) == 1 - assert isinstance(starts[0].content, TextContent) - assert len(deltas) >= 1 - all_delta_text = "".join( - d.delta.text_delta for d in deltas if isinstance(d.delta, TextDelta) and d.delta.text_delta is not None - ) - assert "Hi" in all_delta_text - assert len(dones) == 1 - - async def test_text_indices_are_monotonic(self) -> None: - events = [ - {"type": "item.started", "item": {"id": "m1", "type": "agent_message", "text": "A"}}, - {"type": "item.completed", "item": {"id": "m1", "type": "agent_message", "text": "A"}}, - ] - out = await _collect(convert_codex_to_agentex_events(_aiter(events))) - anchor = [e for e in out if isinstance(e, StreamTaskMessageStart)] - done = [e for e in out if isinstance(e, StreamTaskMessageDone)] - assert anchor[0].index == done[0].index - - async def test_empty_text_no_delta(self) -> None: - events = [ - {"type": "item.started", "item": {"id": "m1", "type": "agent_message", "text": ""}}, - {"type": "item.completed", "item": {"id": "m1", "type": "agent_message", "text": ""}}, - ] - out = await _collect(convert_codex_to_agentex_events(_aiter(events))) - deltas = [e for e in out if isinstance(e, StreamTaskMessageDelta)] - assert deltas == [] - - async def test_text_author_is_agent(self) -> None: - events = [ - {"type": "item.started", "item": {"id": "m1", "type": "agent_message", "text": "X"}}, - ] - out = await _collect(convert_codex_to_agentex_events(_aiter(events))) - for e in out: - content = getattr(e, "content", None) - if content and hasattr(content, "author"): - assert content.author == "agent" - - -# --------------------------------------------------------------------------- -# Tool call streaming -# --------------------------------------------------------------------------- - - -class TestToolCallStreaming: - async def test_command_execution_start_done_full(self) -> None: - events = [ - { - "type": "item.started", - "item": { - "id": "t1", - "type": "command_execution", - "command": "echo hello", - }, - }, - { - "type": "item.completed", - "item": { - "id": "t1", - "type": "command_execution", - "command": "echo hello", - "aggregated_output": "hello", - "exit_code": 0, - }, - }, - ] - out = await _collect(convert_codex_to_agentex_events(_aiter(events))) - - starts = [e for e in out if isinstance(e, StreamTaskMessageStart)] - dones = [e for e in out if isinstance(e, StreamTaskMessageDone)] - fulls = [e for e in out if isinstance(e, StreamTaskMessageFull)] - - assert len(starts) == 1 - assert isinstance(starts[0].content, ToolRequestContent) - assert starts[0].content.name == "bash" - assert starts[0].content.arguments == {"command": "echo hello"} - assert starts[0].content.tool_call_id == "t1" - - assert len(dones) == 1 - - assert len(fulls) == 1 - assert isinstance(fulls[0].content, ToolResponseContent) - resp_content = fulls[0].content.content - assert isinstance(resp_content, dict) - assert resp_content["result"] == "hello" - assert fulls[0].content.tool_call_id == "t1" - - async def test_empty_item_id_request_response_ids_match(self) -> None: - """A tool with an empty item_id must use the SAME fallback tool_call_id - on the request (started) and response (completed) halves.""" - events = [ - {"type": "item.started", "item": {"id": "", "type": "command_execution", "command": "ls"}}, - { - "type": "item.completed", - "item": { - "id": "", - "type": "command_execution", - "command": "ls", - "aggregated_output": ".", - "exit_code": 0, - }, - }, - ] - out = await _collect(convert_codex_to_agentex_events(_aiter(events))) - # Pull tool_call_id inside the comprehension so the isinstance narrows the - # content union (the narrowing would not survive a later attribute access). - req_ids = [ - e.content.tool_call_id - for e in out - if isinstance(e, StreamTaskMessageStart) and isinstance(e.content, ToolRequestContent) - ] - resp_ids = [ - e.content.tool_call_id - for e in out - if isinstance(e, StreamTaskMessageFull) and isinstance(e.content, ToolResponseContent) - ] - assert len(req_ids) == 1 and len(resp_ids) == 1 - assert req_ids[0] == resp_ids[0] - - async def test_file_change_synthesizes_start(self) -> None: - """file_change items may only emit item.completed (no started).""" - events = [ - { - "type": "item.completed", - "item": { - "id": "fc1", - "type": "file_change", - "changes": ["a.py"], - "status": "ok", - }, - } - ] - out = await _collect(convert_codex_to_agentex_events(_aiter(events))) - tool_req = [ - e for e in out if isinstance(e, StreamTaskMessageFull) and isinstance(e.content, ToolRequestContent) - ] - tool_resp = [ - e for e in out if isinstance(e, StreamTaskMessageFull) and isinstance(e.content, ToolResponseContent) - ] - assert len(tool_req) == 1 - assert isinstance(tool_req[0].content, ToolRequestContent) - assert tool_req[0].content.name == "file_change" - assert len(tool_resp) == 1 - - async def test_mcp_tool_call_name(self) -> None: - events = [ - { - "type": "item.started", - "item": { - "id": "mcp1", - "type": "mcp_tool_call", - "server": "fs", - "tool": "read", - "arguments": {"path": "/x"}, - }, - }, - { - "type": "item.completed", - "item": { - "id": "mcp1", - "type": "mcp_tool_call", - "server": "fs", - "tool": "read", - "arguments": {"path": "/x"}, - "result": "content", - }, - }, - ] - out = await _collect(convert_codex_to_agentex_events(_aiter(events))) - req = next( - e for e in out if isinstance(e, StreamTaskMessageStart) and isinstance(e.content, ToolRequestContent) - ) - assert isinstance(req.content, ToolRequestContent) - assert req.content.name == "fs.read" - - async def test_tool_error_marks_is_error(self) -> None: - events = [ - { - "type": "item.started", - "item": {"id": "cmd1", "type": "command_execution", "command": "bad"}, - }, - { - "type": "item.completed", - "item": { - "id": "cmd1", - "type": "command_execution", - "command": "bad", - "aggregated_output": "error output", - "exit_code": 127, - }, - }, - ] - out = await _collect(convert_codex_to_agentex_events(_aiter(events))) - resp = next( - e for e in out if isinstance(e, StreamTaskMessageFull) and isinstance(e.content, ToolResponseContent) - ) - assert isinstance(resp.content, ToolResponseContent) - resp_body = resp.content.content - assert isinstance(resp_body, dict) - assert resp_body.get("is_error") is True - - async def test_tool_indices_request_before_response(self) -> None: - events = [ - { - "type": "item.started", - "item": {"id": "cmd2", "type": "command_execution", "command": "ls"}, - }, - { - "type": "item.completed", - "item": { - "id": "cmd2", - "type": "command_execution", - "command": "ls", - "aggregated_output": ".", - "exit_code": 0, - }, - }, - ] - out = await _collect(convert_codex_to_agentex_events(_aiter(events))) - req = next(e for e in out if isinstance(e, StreamTaskMessageStart)) - resp = next( - e for e in out if isinstance(e, StreamTaskMessageFull) and isinstance(e.content, ToolResponseContent) - ) - assert req.index is not None and resp.index is not None - assert req.index < resp.index - - -# --------------------------------------------------------------------------- -# Reasoning -# --------------------------------------------------------------------------- - - -class TestReasoningStreaming: - async def test_reasoning_start_deltas_done(self) -> None: - """A reasoning block opens with a Start, streams the final text as - summary + content deltas, and closes with a Done. - - It must NOT emit a Full at the open Start's index: auto_send routes a - Full into a throwaway streaming context (ignoring the index), which - would leave the Start context dangling and persist a duplicate, empty - reasoning message (AGX1 codex reasoning duplicate bug). - """ - events = [ - {"type": "item.started", "item": {"id": "r1", "type": "reasoning", "text": ""}}, - { - "type": "item.updated", - "item": {"id": "r1", "type": "reasoning", "text": "thinking..."}, - }, - { - "type": "item.completed", - "item": {"id": "r1", "type": "reasoning", "text": "thinking... done"}, - }, - ] - out = await _collect(convert_codex_to_agentex_events(_aiter(events))) - - starts = [e for e in out if isinstance(e, StreamTaskMessageStart)] - dones = [e for e in out if isinstance(e, StreamTaskMessageDone)] - reasoning_fulls = [ - e for e in out if isinstance(e, StreamTaskMessageFull) and isinstance(e.content, ReasoningContent) - ] - content_deltas = [ - e for e in out if isinstance(e, StreamTaskMessageDelta) and isinstance(e.delta, ReasoningContentDelta) - ] - summary_deltas = [ - e for e in out if isinstance(e, StreamTaskMessageDelta) and isinstance(e.delta, ReasoningSummaryDelta) - ] - - # Exactly one message: Start + deltas + Done, all on the same index, no Full. - assert len(starts) == 1 - assert isinstance(starts[0].content, ReasoningContent) - assert reasoning_fulls == [] - assert len(content_deltas) == 1 - content_delta = content_deltas[0].delta - assert isinstance(content_delta, ReasoningContentDelta) - assert content_delta.content_delta == "thinking... done" - assert len(summary_deltas) == 1 - summary_delta = summary_deltas[0].delta - assert isinstance(summary_delta, ReasoningSummaryDelta) - assert summary_delta.summary_delta == "thinking... done" - assert len(dones) == 1 - idx = starts[0].index - assert content_deltas[0].index == idx - assert summary_deltas[0].index == idx - assert dones[0].index == idx - - async def test_reasoning_no_started_opens_and_closes_one_message(self) -> None: - """If item.completed arrives without item.started, the converter opens a - Start lazily and closes it with a Done (still one clean message, no Full).""" - events = [ - { - "type": "item.completed", - "item": {"id": "r_orphan", "type": "reasoning", "text": "orphan thought"}, - } - ] - out = await _collect(convert_codex_to_agentex_events(_aiter(events))) - - starts = [e for e in out if isinstance(e, StreamTaskMessageStart)] - dones = [e for e in out if isinstance(e, StreamTaskMessageDone)] - reasoning_fulls = [ - e for e in out if isinstance(e, StreamTaskMessageFull) and isinstance(e.content, ReasoningContent) - ] - content_deltas = [ - e for e in out if isinstance(e, StreamTaskMessageDelta) and isinstance(e.delta, ReasoningContentDelta) - ] - - assert len(starts) == 1 - assert isinstance(starts[0].content, ReasoningContent) - assert reasoning_fulls == [] - assert len(content_deltas) == 1 - content_delta = content_deltas[0].delta - assert isinstance(content_delta, ReasoningContentDelta) - assert content_delta.content_delta == "orphan thought" - assert len(dones) == 1 - assert dones[0].index == starts[0].index - - async def test_reasoning_summary_is_first_line(self) -> None: - events = [ - {"type": "item.started", "item": {"id": "r2", "type": "reasoning", "text": ""}}, - { - "type": "item.completed", - "item": {"id": "r2", "type": "reasoning", "text": "line one\nline two"}, - }, - ] - out = await _collect(convert_codex_to_agentex_events(_aiter(events))) - summary_event = next( - e for e in out if isinstance(e, StreamTaskMessageDelta) and isinstance(e.delta, ReasoningSummaryDelta) - ) - summary_delta = summary_event.delta - assert isinstance(summary_delta, ReasoningSummaryDelta) - assert summary_delta.summary_delta == "line one" - - async def test_reasoning_empty_block_closes_with_done_only(self) -> None: - """A reasoning block that completes with no text still closes its Start.""" - events = [ - {"type": "item.started", "item": {"id": "r3", "type": "reasoning", "text": ""}}, - {"type": "item.completed", "item": {"id": "r3", "type": "reasoning", "text": ""}}, - ] - out = await _collect(convert_codex_to_agentex_events(_aiter(events))) - - starts = [e for e in out if isinstance(e, StreamTaskMessageStart)] - dones = [e for e in out if isinstance(e, StreamTaskMessageDone)] - deltas = [e for e in out if isinstance(e, StreamTaskMessageDelta)] - - assert len(starts) == 1 - assert deltas == [] - assert len(dones) == 1 - assert dones[0].index == starts[0].index - - -# --------------------------------------------------------------------------- -# Error events -# --------------------------------------------------------------------------- - - -class TestErrorEvents: - async def test_turn_failed_emits_error_text(self) -> None: - events = [{"type": "turn.failed", "error": {"message": "context length exceeded"}}] - out = await _collect(convert_codex_to_agentex_events(_aiter(events))) - assert len(out) == 1 - assert isinstance(out[0], StreamTaskMessageFull) - assert isinstance(out[0].content, TextContent) - assert "context length exceeded" in out[0].content.content - - async def test_top_level_error_emits_text(self) -> None: - events = [{"type": "error", "message": "unexpected EOF"}] - out = await _collect(convert_codex_to_agentex_events(_aiter(events))) - assert len(out) == 1 - assert isinstance(out[0].content, TextContent) - assert "unexpected EOF" in out[0].content.content - - async def test_item_error_emits_on_completed_only(self) -> None: - events = [ - {"type": "item.started", "item": {"id": "e1", "type": "error", "message": "bad"}}, - {"type": "item.completed", "item": {"id": "e1", "type": "error", "message": "bad"}}, - ] - out = await _collect(convert_codex_to_agentex_events(_aiter(events))) - # Only item.completed emits an event for error items - assert len(out) == 1 - assert isinstance(out[0].content, TextContent) - assert "bad" in out[0].content.content - - -# --------------------------------------------------------------------------- -# Edge cases -# --------------------------------------------------------------------------- - - -class TestEdgeCases: - async def test_empty_stream(self) -> None: - out = await _collect(convert_codex_to_agentex_events(_aiter([]))) - assert out == [] - - async def test_non_json_lines_skipped(self) -> None: - events: list[str] = ["not json", "also not json"] - out = await _collect(convert_codex_to_agentex_events(_aiter(events))) - assert out == [] - - async def test_blank_lines_skipped(self) -> None: - out = await _collect(convert_codex_to_agentex_events(_aiter(["", " ", "\n"]))) - assert out == [] - - async def test_pre_decoded_dict_events(self) -> None: - """Events passed as dicts (pre-decoded) should work without JSON parsing.""" - events: list[dict[str, Any]] = [ - {"type": "item.started", "item": {"id": "m1", "type": "agent_message", "text": "hi"}}, - { - "type": "item.completed", - "item": {"id": "m1", "type": "agent_message", "text": "hi"}, - }, - ] - out = await _collect(convert_codex_to_agentex_events(_aiter(events))) - assert len(out) > 0 - - async def test_thread_started_no_message(self) -> None: - events = [{"type": "thread.started", "thread_id": "t1"}] - out = await _collect(convert_codex_to_agentex_events(_aiter(events))) - assert out == [] - - async def test_turn_started_no_message(self) -> None: - out = await _collect(convert_codex_to_agentex_events(_aiter([{"type": "turn.started"}]))) - assert out == [] - - async def test_turn_completed_no_message(self) -> None: - out = await _collect( - convert_codex_to_agentex_events(_aiter([{"type": "turn.completed", "usage": {"input_tokens": 1}}])) - ) - assert out == [] - - async def test_unknown_event_type_no_message(self) -> None: - out = await _collect(convert_codex_to_agentex_events(_aiter([{"type": "some.future.event"}]))) - assert out == [] - - async def test_unknown_item_type_no_message(self) -> None: - out = await _collect( - convert_codex_to_agentex_events( - _aiter([{"type": "item.started", "item": {"id": "x", "type": "future_item"}}]) - ) - ) - assert out == [] - - -# --------------------------------------------------------------------------- -# on_result callback -# --------------------------------------------------------------------------- - - -class TestOnResult: - async def test_session_id_captured(self) -> None: - result: dict[str, Any] = {} - - def on_result(r: dict[str, Any]) -> None: - result.update(r) - - events = [ - {"type": "thread.started", "thread_id": "sess-xyz"}, - { - "type": "turn.completed", - "usage": {"input_tokens": 5, "output_tokens": 3, "total_tokens": 8}, - }, - ] - await _collect(convert_codex_to_agentex_events(_aiter(events), on_result=on_result)) - assert result["session_id"] == "sess-xyz" - - async def test_usage_forwarded(self) -> None: - result: dict[str, Any] = {} - - def on_result(r: dict[str, Any]) -> None: - result.update(r) - - events = [ - { - "type": "turn.completed", - "usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, - } - ] - await _collect(convert_codex_to_agentex_events(_aiter(events), on_result=on_result)) - assert result["usage"] == {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15} - - async def test_tool_count(self) -> None: - result: dict[str, Any] = {} - - def on_result(r: dict[str, Any]) -> None: - result.update(r) - - events = [ - { - "type": "item.started", - "item": {"id": "t1", "type": "command_execution", "command": "ls"}, - }, - { - "type": "item.completed", - "item": { - "id": "t1", - "type": "command_execution", - "command": "ls", - "aggregated_output": ".", - "exit_code": 0, - }, - }, - {"type": "turn.completed", "usage": None}, - ] - await _collect(convert_codex_to_agentex_events(_aiter(events), on_result=on_result)) - assert result["tool_call_count"] == 1 - - async def test_no_callback_when_none(self) -> None: - """Passing on_result=None should not raise.""" - events = [{"type": "turn.completed", "usage": None}] - out = await _collect(convert_codex_to_agentex_events(_aiter(events), on_result=None)) - assert out == [] - - async def test_on_result_called_even_without_turn_completed(self) -> None: - """on_result fires at end of stream even if turn.completed never arrived.""" - result: dict[str, Any] = {} - - def on_result(r: dict[str, Any]) -> None: - result.update(r) - - events: list[Any] = [] - await _collect(convert_codex_to_agentex_events(_aiter(events), on_result=on_result)) - assert result.get("usage") is None - assert result.get("session_id") is None - - -# --------------------------------------------------------------------------- -# Multi-step turn: tool โ†’ text -# --------------------------------------------------------------------------- - - -class TestMultiStepTurn: - async def test_tool_then_text_monotonic_indices(self) -> None: - events = [ - { - "type": "item.started", - "item": {"id": "cmd1", "type": "command_execution", "command": "ls"}, - }, - { - "type": "item.completed", - "item": { - "id": "cmd1", - "type": "command_execution", - "command": "ls", - "aggregated_output": "file.txt", - "exit_code": 0, - }, - }, - { - "type": "item.started", - "item": {"id": "msg1", "type": "agent_message", "text": ""}, - }, - { - "type": "item.completed", - "item": {"id": "msg1", "type": "agent_message", "text": "Done"}, - }, - ] - out = await _collect(convert_codex_to_agentex_events(_aiter(events))) - indices = [e.index for e in out] - assert indices == sorted(indices), "indices must be monotonically non-decreasing" - - async def test_two_text_blocks_distinct_indices(self) -> None: - events = [ - { - "type": "item.started", - "item": {"id": "a", "type": "agent_message", "text": "first"}, - }, - {"type": "item.completed", "item": {"id": "a", "type": "agent_message", "text": "first"}}, - { - "type": "item.started", - "item": {"id": "b", "type": "agent_message", "text": "second"}, - }, - {"type": "item.completed", "item": {"id": "b", "type": "agent_message", "text": "second"}}, - ] - out = await _collect(convert_codex_to_agentex_events(_aiter(events))) - starts = [e for e in out if isinstance(e, StreamTaskMessageStart)] - assert len(starts) == 2 - assert starts[0].index != starts[1].index - - async def test_json_string_events(self) -> None: - """Events may arrive as raw newline-delimited JSON strings.""" - raw_events = [ - json.dumps({"type": "item.started", "item": {"id": "s1", "type": "agent_message", "text": "hello"}}), - json.dumps({"type": "item.completed", "item": {"id": "s1", "type": "agent_message", "text": "hello"}}), - ] - out = await _collect(convert_codex_to_agentex_events(_aiter(raw_events))) - assert len(out) > 0 - assert any(isinstance(e, StreamTaskMessageStart) for e in out) diff --git a/tests/lib/adk/test_codex_turn.py b/tests/lib/adk/test_codex_turn.py deleted file mode 100644 index c2843e90e..000000000 --- a/tests/lib/adk/test_codex_turn.py +++ /dev/null @@ -1,341 +0,0 @@ -"""Offline tests for CodexTurn and codex_usage_to_turn_usage. - -Tests cover: -- TurnUsage normalization from raw codex usage dicts -- Defensive handling of missing/invalid usage fields -- CodexTurn: events property yields canonical StreamTaskMessage* -- CodexTurn: usage() before and after stream exhaustion -- CodexTurn: on_result wiring (session_id, counts propagate to usage()) -- CodexTurn satisfies HarnessTurn protocol -""" - -from __future__ import annotations - -from typing import Any, AsyncIterator - -import pytest - -from agentex.lib.core.harness.types import TurnUsage, HarnessTurn -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.lib.adk._modules._codex_turn import ( - CodexTurn, - codex_usage_to_turn_usage, -) - - -async def _aiter(items: list[Any]) -> AsyncIterator[Any]: - for item in items: - yield item - - -async def _collect(turn: CodexTurn) -> list[Any]: - return [msg async for msg in turn.events] - - -# --------------------------------------------------------------------------- -# codex_usage_to_turn_usage -# --------------------------------------------------------------------------- - - -class TestCodexUsageToTurnUsage: - def test_none_raw_all_none_tokens(self) -> None: - u = codex_usage_to_turn_usage(None) - assert u.input_tokens is None - assert u.output_tokens is None - assert u.total_tokens is None - assert u.cost_usd is None - - def test_empty_dict_all_none_tokens(self) -> None: - u = codex_usage_to_turn_usage({}) - assert u.input_tokens is None - assert u.output_tokens is None - - def test_standard_usage(self) -> None: - raw = {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150} - u = codex_usage_to_turn_usage(raw, model="o4-mini") - assert u.input_tokens == 100 - assert u.output_tokens == 50 - assert u.total_tokens == 150 - assert u.model == "o4-mini" - - def test_reasoning_tokens(self) -> None: - raw = {"input_tokens": 200, "output_tokens": 80, "reasoning_tokens": 60, "total_tokens": 340} - u = codex_usage_to_turn_usage(raw) - assert u.reasoning_tokens == 60 - - def test_real_zero_preserved(self) -> None: - """Explicit zeros in the payload must survive (not be treated as missing).""" - raw = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0} - u = codex_usage_to_turn_usage(raw) - assert u.input_tokens == 0 - assert u.output_tokens == 0 - - def test_cached_input_tokens(self) -> None: - raw = {"input_tokens": 100, "cached_input_tokens": 20, "output_tokens": 40} - u = codex_usage_to_turn_usage(raw) - assert u.cached_input_tokens == 20 - - def test_invalid_token_values_become_none(self) -> None: - raw = {"input_tokens": "not_a_number", "output_tokens": None} - u = codex_usage_to_turn_usage(raw) - assert u.input_tokens is None - assert u.output_tokens is None - - def test_cost_explicit(self) -> None: - u = codex_usage_to_turn_usage(None, cost_usd=0.0042) - assert u.cost_usd == pytest.approx(0.0042) - - def test_cost_from_raw(self) -> None: - u = codex_usage_to_turn_usage({"cost_usd": 0.001}) - assert u.cost_usd == pytest.approx(0.001) - - def test_explicit_cost_overrides_raw(self) -> None: - """Explicit cost_usd kwarg takes precedence over raw dict value.""" - u = codex_usage_to_turn_usage({"cost_usd": 0.001}, cost_usd=0.002) - assert u.cost_usd == pytest.approx(0.002) - - def test_tool_and_reasoning_counts(self) -> None: - u = codex_usage_to_turn_usage(None, tool_call_count=3, reasoning_count=2) - assert u.num_tool_calls == 3 - assert u.num_reasoning_blocks == 2 - - def test_num_llm_calls_always_one(self) -> None: - u = codex_usage_to_turn_usage(None) - assert u.num_llm_calls == 1 - - def test_duration_ms(self) -> None: - u = codex_usage_to_turn_usage(None, duration_ms=1234) - assert u.duration_ms == 1234 - - def test_model_none_when_not_provided(self) -> None: - u = codex_usage_to_turn_usage(None) - assert u.model is None - - def test_non_dict_raw_treated_as_empty(self) -> None: - u = codex_usage_to_turn_usage("bad input") # type: ignore[arg-type] - assert u.input_tokens is None - - def test_returns_turn_usage_instance(self) -> None: - u = codex_usage_to_turn_usage({}) - assert isinstance(u, TurnUsage) - - -# --------------------------------------------------------------------------- -# CodexTurn protocol conformance -# --------------------------------------------------------------------------- - - -class TestCodexTurnProtocol: - def test_implements_harness_turn_protocol(self) -> None: - turn = CodexTurn(_aiter([]), model="o4-mini") - assert isinstance(turn, HarnessTurn) - - def test_usage_before_exhaustion_returns_zero_turn_usage(self) -> None: - turn = CodexTurn(_aiter([]), model="test-model") - u = turn.usage() - assert isinstance(u, TurnUsage) - assert u.model == "test-model" - assert u.input_tokens is None - assert u.num_tool_calls == 0 - - -# --------------------------------------------------------------------------- -# CodexTurn events -# --------------------------------------------------------------------------- - - -class TestCodexTurnEvents: - async def test_events_yield_stream_task_messages(self) -> None: - events = [ - {"type": "item.started", "item": {"id": "m1", "type": "agent_message", "text": "hi"}}, - {"type": "item.completed", "item": {"id": "m1", "type": "agent_message", "text": "hi"}}, - ] - turn = CodexTurn(_aiter(events), model="o4-mini") - out = await _collect(turn) - assert len(out) > 0 - for msg in out: - assert isinstance( - msg, - (StreamTaskMessageStart, StreamTaskMessageDelta, StreamTaskMessageFull, StreamTaskMessageDone), - ) - - async def test_usage_after_exhaustion_has_tokens(self) -> None: - events = [ - { - "type": "turn.completed", - "usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, - } - ] - turn = CodexTurn(_aiter(events), model="o4-mini") - await _collect(turn) - u = turn.usage() - assert u.input_tokens == 10 - assert u.output_tokens == 5 - assert u.total_tokens == 15 - - async def test_usage_model_propagated(self) -> None: - events = [{"type": "turn.completed", "usage": None}] - turn = CodexTurn(_aiter(events), model="codex-model-x") - await _collect(turn) - assert turn.usage().model == "codex-model-x" - - async def test_tool_count_in_usage(self) -> None: - events = [ - { - "type": "item.started", - "item": {"id": "t1", "type": "command_execution", "command": "ls"}, - }, - { - "type": "item.completed", - "item": { - "id": "t1", - "type": "command_execution", - "command": "ls", - "aggregated_output": ".", - "exit_code": 0, - }, - }, - {"type": "turn.completed", "usage": None}, - ] - turn = CodexTurn(_aiter(events), model="o4-mini") - await _collect(turn) - assert turn.usage().num_tool_calls == 1 - - async def test_events_property_stable_across_accesses(self) -> None: - """`.events` returns the same generator; usage survives a second access.""" - events = [ - { - "type": "item.started", - "item": {"id": "t1", "type": "command_execution", "command": "ls"}, - }, - { - "type": "item.completed", - "item": { - "id": "t1", - "type": "command_execution", - "command": "ls", - "aggregated_output": ".", - "exit_code": 0, - }, - }, - {"type": "turn.completed", "usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}}, - ] - turn = CodexTurn(_aiter(events), model="o4-mini") - assert turn.events is turn.events # same generator, not a fresh wrapper - await _collect(turn) - # A second access must NOT re-wrap the exhausted iterator and reset usage. - _ = turn.events - assert turn.usage().total_tokens == 15 - assert turn.usage().num_tool_calls == 1 - - async def test_reasoning_count_in_usage(self) -> None: - events = [ - {"type": "item.started", "item": {"id": "r1", "type": "reasoning", "text": ""}}, - { - "type": "item.completed", - "item": {"id": "r1", "type": "reasoning", "text": "thought"}, - }, - {"type": "turn.completed", "usage": None}, - ] - turn = CodexTurn(_aiter(events), model="o4-mini") - await _collect(turn) - assert turn.usage().num_reasoning_blocks == 1 - - async def test_duration_ms_passed_through(self) -> None: - events = [{"type": "turn.completed", "usage": None}] - turn = CodexTurn(_aiter(events), model="o4-mini", duration_ms=999) - await _collect(turn) - assert turn.usage().duration_ms == 999 - - async def test_cost_usd_passed_through(self) -> None: - events = [{"type": "turn.completed", "usage": None}] - turn = CodexTurn(_aiter(events), model="o4-mini", cost_usd=0.007) - await _collect(turn) - assert turn.usage().cost_usd == pytest.approx(0.007) - - async def test_empty_stream_usage_still_valid(self) -> None: - turn = CodexTurn(_aiter([]), model="o4-mini") - await _collect(turn) - u = turn.usage() - assert isinstance(u, TurnUsage) - assert u.num_llm_calls == 1 - - async def test_reasoning_tokens_propagated(self) -> None: - events = [ - { - "type": "turn.completed", - "usage": { - "input_tokens": 100, - "output_tokens": 60, - "reasoning_tokens": 40, - "total_tokens": 200, - }, - } - ] - turn = CodexTurn(_aiter(events), model="o4-mini") - await _collect(turn) - assert turn.usage().reasoning_tokens == 40 - - -# --------------------------------------------------------------------------- -# Early session_id capture (resume after interrupt) -# --------------------------------------------------------------------------- - - -class TestCodexTurnSessionIdCapture: - async def test_session_id_from_result_when_turn_completes(self) -> None: - events = [ - {"type": "thread.started", "thread_id": "thread-abc"}, - {"type": "turn.completed", "usage": None}, - ] - turn = CodexTurn(_aiter(events), model="o4-mini") - await _collect(turn) - # on_result carries session_id from processor.session_id at end of stream. - assert turn.session_id == "thread-abc" - - async def test_session_id_from_init_when_interrupted_before_completion(self) -> None: - """thread.started must yield session_id even with no turn.completed. - - Mirrors the claude-code early-capture fix: an interrupted-before-completion - codex turn never emits turn.completed, so the early thread.started capture - is what keeps it resumable. - """ - events = [ - {"type": "thread.started", "thread_id": "thread-abc"}, - # stream ends here (interrupted) โ€” no turn.completed - ] - turn = CodexTurn(_aiter(events), model="o4-mini") - await _collect(turn) - assert turn.session_id == "thread-abc" - - async def test_session_id_none_without_thread_started(self) -> None: - turn = CodexTurn(_aiter([{"type": "turn.completed", "usage": None}]), model="o4-mini") - await _collect(turn) - assert turn.session_id is None - - async def test_guarded_events_closes_source_on_early_break(self) -> None: - closed = {"value": False} - - async def _events(): - try: - yield {"type": "thread.started", "thread_id": "thread-abc"} - yield { - "type": "item.completed", - "item": {"id": "m1", "type": "agent_message", "text": "hi"}, - } - finally: - closed["value"] = True - - turn = CodexTurn(_events(), model="o4-mini") - gen = turn.events - async for _ in gen: - break - gen_aclose = getattr(gen, "aclose", None) - assert gen_aclose is not None - await gen_aclose() - assert closed["value"] is True diff --git a/tests/lib/adk/test_langgraph_async.py b/tests/lib/adk/test_langgraph_async.py deleted file mode 100644 index ebe215a15..000000000 --- a/tests/lib/adk/test_langgraph_async.py +++ /dev/null @@ -1,282 +0,0 @@ -"""Characterization tests for stream_langgraph_events (unified surface). - -These tests verify the behavior of ``stream_langgraph_events`` after it was -reimplemented on top of ``LangGraphTurn`` + ``UnifiedEmitter.auto_send_turn`` -(Task 4). They serve as a contract test for the public signature. - -Key behavioral notes (unified surface vs. old bespoke implementation): -- Tool calls/responses are posted via ``streaming_task_message_context`` (not - ``adk.messages.create``); they appear as contexts with no stream_update calls. -- ``final_text`` accumulates ALL text across the turn (the old bespoke impl - only returned the last text segment โ€” behavior varied across models). - -NOTE: langchain_core imports are deferred to test scope because conftest.py -stubs ``langchain_core.messages`` with MagicMock. -""" - -from __future__ import annotations - -import sys -from typing import Any -from dataclasses import field, dataclass - -import pytest - -from agentex.types.task_message import TaskMessage -from agentex.types.text_content import TextContent -from agentex.types.task_message_delta import TextDelta -from agentex.types.task_message_update import StreamTaskMessageDelta -from agentex.lib.adk._modules._langgraph_turn import stream_langgraph_events - -TASK_ID = "task-test" - - -# --------------------------------------------------------------------------- -# Remove conftest stubs so real langchain_core types are used -# --------------------------------------------------------------------------- - - -@pytest.fixture(autouse=True) -def _real_langchain_core(): - stub_keys = [k for k in sys.modules if k.startswith("langchain_core") or k.startswith("langgraph")] - saved = {k: sys.modules.pop(k) for k in stub_keys} - import importlib - - importlib.import_module("langchain_core.messages") - yield - sys.modules.update(saved) - - -# --------------------------------------------------------------------------- -# Fake streaming infrastructure (mirrors test_pydantic_ai_async.py pattern) -# --------------------------------------------------------------------------- - - -@dataclass -class FakeContext: - initial_content: Any - task_message: TaskMessage - closed: bool = False - updates: list[StreamTaskMessageDelta] = field(default_factory=list) - - async def __aenter__(self) -> "FakeContext": - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb) -> bool: - await self.close() - return False - - async def stream_update(self, update: StreamTaskMessageDelta) -> None: - if self.closed: - raise AssertionError("stream_update called after close") - self.updates.append(update) - - async def close(self) -> None: - self.closed = True - - -class FakeStreamingModule: - def __init__(self) -> None: - self.contexts: list[FakeContext] = [] - - def streaming_task_message_context(self, *, task_id: str, initial_content: Any, **kw: Any) -> FakeContext: - tm = TaskMessage( - id=f"m{len(self.contexts) + 1}", - task_id=task_id, - content=initial_content, - streaming_status="IN_PROGRESS", - ) - ctx = FakeContext(initial_content=initial_content, task_message=tm) - self.contexts.append(ctx) - return ctx - - -class FakeMessagesModule: - def __init__(self) -> None: - self.created: list[dict[str, Any]] = [] - - async def create(self, *, task_id: str, content: Any) -> TaskMessage: - self.created.append({"task_id": task_id, "content": content}) - return TaskMessage( - id=f"created-{len(self.created)}", - task_id=task_id, - content=content, - streaming_status="DONE", - ) - - -@pytest.fixture -def fake_adk(monkeypatch): - from agentex.lib import adk as adk_module - - streaming = FakeStreamingModule() - messages = FakeMessagesModule() - monkeypatch.setattr(adk_module, "streaming", streaming) - monkeypatch.setattr(adk_module, "messages", messages) - return streaming, messages - - -def _make_stream(events: list[tuple[str, Any]]): - async def _gen(): - for e in events: - yield e - - return _gen() - - -def _text_deltas(ctx: FakeContext) -> list[str]: - out: list[str] = [] - for u in ctx.updates: - if isinstance(u.delta, TextDelta): - out.append(u.delta.text_delta or "") - return out - - -# --------------------------------------------------------------------------- -# Characterization tests (unified surface behavior) -# --------------------------------------------------------------------------- - - -class TestCharacterization: - async def test_plain_text_streams_and_returns_final_text( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - from langchain_core.messages import AIMessage, AIMessageChunk - - streaming, messages = fake_adk - chunk = AIMessageChunk(content="Hello, world!") - ai_msg = AIMessage(content="Hello, world!") - stream = _make_stream( - [ - ("messages", (chunk, {})), - ("updates", {"agent": {"messages": [ai_msg]}}), - ] - ) - - final = await stream_langgraph_events(stream, TASK_ID) - - assert final == "Hello, world!" - assert len(streaming.contexts) == 1 - ctx = streaming.contexts[0] - assert isinstance(ctx.initial_content, TextContent) - assert _text_deltas(ctx) == ["Hello, world!"] - assert ctx.closed is True - # Unified surface: no messages.create for text - assert messages.created == [] - - async def test_empty_stream_returns_empty_string( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - streaming, _ = fake_adk - final = await stream_langgraph_events(_make_stream([]), TASK_ID) - assert final == "" - assert streaming.contexts == [] - - async def test_tool_call_posted_via_streaming_context( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - """Unified surface: tool calls go through streaming_task_message_context, - not adk.messages.create. The context is opened and immediately closed - (no deltas) so the initial_content is the tool request.""" - from langchain_core.messages import AIMessage - - streaming, messages = fake_adk - tc = {"id": "call_1", "name": "get_weather", "args": {"city": "Paris"}} - ai_msg = AIMessage(content="", tool_calls=[tc]) - stream = _make_stream([("updates", {"agent": {"messages": [ai_msg]}})]) - - await stream_langgraph_events(stream, TASK_ID) - - # Unified surface: tool messages go via streaming_task_message_context - assert len(streaming.contexts) == 1 - assert messages.created == [], "Unified surface uses streaming_task_message_context, not messages.create" - - from agentex.types.tool_request_content import ToolRequestContent - - content = streaming.contexts[0].initial_content - assert isinstance(content, ToolRequestContent) - assert content.tool_call_id == "call_1" - assert content.name == "get_weather" - assert content.arguments == {"city": "Paris"} - # Full messages close immediately (no delta updates) - assert streaming.contexts[0].closed is True - assert streaming.contexts[0].updates == [] - - async def test_tool_response_posted_via_streaming_context( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - """Unified surface: tool responses go through streaming_task_message_context.""" - from langchain_core.messages import ToolMessage - - streaming, messages = fake_adk - tool_msg = ToolMessage(content="Sunny, 72F", tool_call_id="call_1", name="get_weather") - stream = _make_stream([("updates", {"tools": {"messages": [tool_msg]}})]) - - await stream_langgraph_events(stream, TASK_ID) - - assert len(streaming.contexts) == 1 - assert messages.created == [] - - from agentex.types.tool_response_content import ToolResponseContent - - content = streaming.contexts[0].initial_content - assert isinstance(content, ToolResponseContent) - assert content.tool_call_id == "call_1" - assert content.name == "get_weather" - assert content.content == "Sunny, 72F" - assert streaming.contexts[0].closed is True - - async def test_multi_step_text_then_tool_then_text_last_segment( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - """Unified surface: final_text uses last-segment semantics. - - auto_send resets final_text_parts when a new Start(TextContent) is seen, - so multi-step turns (text -> tool -> text) return only the LAST text segment. - Both text contexts are still opened and streamed to Redis; only the - return value is last-segment. This matches stream_pydantic_ai_events. - """ - from langchain_core.messages import AIMessage, ToolMessage, AIMessageChunk - - streaming, messages = fake_adk - chunk1 = AIMessageChunk(content="Looking up...") - ai_msg1 = AIMessage(content="Looking up...", tool_calls=[{"id": "c1", "name": "search", "args": {}}]) - tool_msg = ToolMessage(content="result", tool_call_id="c1", name="search") - chunk2 = AIMessageChunk(content="Found it!") - ai_msg2 = AIMessage(content="Found it!") - - stream = _make_stream( - [ - ("messages", (chunk1, {})), - ("updates", {"agent": {"messages": [ai_msg1]}}), - ("updates", {"tools": {"messages": [tool_msg]}}), - ("messages", (chunk2, {})), - ("updates", {"agent": {"messages": [ai_msg2]}}), - ] - ) - - final = await stream_langgraph_events(stream, TASK_ID) - - # Last segment only โ€” first text segment is NOT in final_text - assert final == "Found it!" - # Two text streaming contexts (one per text segment) โ€” both streamed to Redis - text_ctxs = [c for c in streaming.contexts if isinstance(c.initial_content, TextContent)] - assert len(text_ctxs) == 2 - assert all(ctx.closed for ctx in text_ctxs) - # Tool request + tool response via streaming_task_message_context (not messages.create) - assert messages.created == [] - - async def test_context_closed_on_exception(self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule]) -> None: - from langchain_core.messages import AIMessageChunk - - streaming, _ = fake_adk - - async def _boom(): - chunk = AIMessageChunk(content="partial") - yield ("messages", (chunk, {})) - raise RuntimeError("upstream exploded") - - with pytest.raises(RuntimeError, match="upstream exploded"): - await stream_langgraph_events(_boom(), TASK_ID) - - assert streaming.contexts[0].closed is True diff --git a/tests/lib/adk/test_langgraph_sync.py b/tests/lib/adk/test_langgraph_sync.py deleted file mode 100644 index 9e8c6e4f0..000000000 --- a/tests/lib/adk/test_langgraph_sync.py +++ /dev/null @@ -1,393 +0,0 @@ -"""Tests for the sync LangGraph -> Agentex path. - -Covers: -- The bare converter ``convert_langgraph_to_agentex_events``: - * Basic text, tool call, and tool response emission - * on_final_ai_message callback for usage capture -- The unified sync (HTTP ACP) path ``UnifiedEmitter.yield_turn(LangGraphTurn(...))``: - * Passthrough: yield_turn events equal LangGraphTurn(stream).events - * Span derivation from Full tool events with a fake tracer - -NOTE: langchain_core imports must be deferred to test-function scope because -conftest.py stubs out ``langchain_core.messages`` with MagicMock for ADK -package-level tests. The real classes are imported lazily inside each test. -""" - -from __future__ import annotations - -import sys -from typing import Any, AsyncIterator -from datetime import datetime, timezone -from dataclasses import field, dataclass - -import pytest - -from agentex.lib.core.harness.tracer import SpanTracer -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.types.task_message_update import ( - StreamTaskMessageFull, -) -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.lib.adk._modules._langgraph_sync import convert_langgraph_to_agentex_events -from agentex.lib.adk._modules._langgraph_turn import LangGraphTurn - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -async def _collect(stream: AsyncIterator[Any]) -> list[Any]: - return [e async for e in stream] - - -def _make_stream(events: list[tuple[str, Any]]) -> AsyncIterator[tuple[str, Any]]: - async def _gen(): - for e in events: - yield e - - return _gen() - - -# --------------------------------------------------------------------------- -# Remove the conftest stubs for langchain_core so real classes are used -# --------------------------------------------------------------------------- - - -@pytest.fixture(autouse=True) -def _real_langchain_core(): - """Remove conftest MagicMock stubs so real langchain_core types are used.""" - stub_keys = [k for k in sys.modules if k.startswith("langchain_core") or k.startswith("langgraph")] - saved = {k: sys.modules.pop(k) for k in stub_keys} - # Re-import the real modules - import importlib - - importlib.import_module("langchain_core.messages") - yield - # Restore stubs after the test - sys.modules.update(saved) - - -class TestTextStreaming: - async def test_plain_text_emits_start_delta_done(self): - from langchain_core.messages import AIMessage, AIMessageChunk - - chunk = AIMessageChunk(content="Hello, world!") - events = [ - ("messages", (chunk, {})), - ("updates", {"agent": {"messages": [AIMessage(content="Hello, world!")]}}), - ] - out = await _collect(convert_langgraph_to_agentex_events(_make_stream(events))) - types = [type(e).__name__ for e in out] - assert "StreamTaskMessageStart" in types - assert "StreamTaskMessageDelta" in types - assert "StreamTaskMessageDone" in types - - async def test_empty_chunk_content_is_skipped(self): - from langchain_core.messages import AIMessageChunk - - chunk = AIMessageChunk(content="") - events = [("messages", (chunk, {}))] - out = await _collect(convert_langgraph_to_agentex_events(_make_stream(events))) - assert out == [] - - async def test_reasoning_block_start_wraps_reasoning_content(self): - """A Responses-API reasoning block opens a Start wrapping ReasoningContent, - not TextContent (the deltas are ReasoningContentDelta).""" - from langchain_core.messages import AIMessageChunk - - from agentex.types.reasoning_content import ReasoningContent - from agentex.types.task_message_update import StreamTaskMessageDelta, StreamTaskMessageStart - from agentex.types.reasoning_content_delta import ReasoningContentDelta - - chunk = AIMessageChunk( - content=[{"type": "reasoning", "summary": [{"type": "summary_text", "text": "thinking hard"}]}] - ) - events = [("messages", (chunk, {}))] - out = await _collect(convert_langgraph_to_agentex_events(_make_stream(events))) - starts = [e for e in out if isinstance(e, StreamTaskMessageStart)] - assert len(starts) == 1 - assert isinstance(starts[0].content, ReasoningContent), "reasoning Start must wrap ReasoningContent" - # `style` must be a non-null MessageStyle: the AgentEx server's - # StreamTaskMessageStartEntity rejects `reasoning.style=None` (enum), which - # would kill the stream. Match the conformance fixture's canonical value. - assert starts[0].content.style == "active", "reasoning Start must set a non-null style ('active')" - # Pull content_delta inside the comprehension so the isinstance narrows the - # delta union (narrowing would not survive a later attribute access). - reasoning_delta_texts = [ - e.delta.content_delta - for e in out - if isinstance(e, StreamTaskMessageDelta) and isinstance(e.delta, ReasoningContentDelta) - ] - assert reasoning_delta_texts == ["thinking hard"] - - -class TestToolCallEmission: - async def test_tool_call_emits_full_message(self): - from langchain_core.messages import AIMessage - - tc = {"id": "call_1", "name": "get_weather", "args": {"city": "Paris"}} - ai_msg = AIMessage(content="", tool_calls=[tc]) - events = [("updates", {"agent": {"messages": [ai_msg]}})] - out = await _collect(convert_langgraph_to_agentex_events(_make_stream(events))) - assert len(out) == 1 - assert isinstance(out[0], StreamTaskMessageFull) - content = out[0].content - assert isinstance(content, ToolRequestContent) - assert content.tool_call_id == "call_1" - assert content.name == "get_weather" - assert content.arguments == {"city": "Paris"} - assert content.author == "agent" - - async def test_tool_response_emits_full_message(self): - from langchain_core.messages import ToolMessage - - tool_msg = ToolMessage(content="Sunny, 72F", tool_call_id="call_1", name="get_weather") - events = [("updates", {"tools": {"messages": [tool_msg]}})] - out = await _collect(convert_langgraph_to_agentex_events(_make_stream(events))) - assert len(out) == 1 - assert isinstance(out[0], StreamTaskMessageFull) - content = out[0].content - assert isinstance(content, ToolResponseContent) - assert content.tool_call_id == "call_1" - assert content.name == "get_weather" - assert content.content == "Sunny, 72F" - assert content.author == "agent" - - -class TestOnFinalAiMessageCallback: - async def test_callback_called_for_ai_message_in_agent_node(self): - from langchain_core.messages import AIMessage - - captured: list[Any] = [] - ai_msg = AIMessage(content="Hello!") - - events = [("updates", {"agent": {"messages": [ai_msg]}})] - await _collect(convert_langgraph_to_agentex_events(_make_stream(events), on_final_ai_message=captured.append)) - assert len(captured) == 1 - assert captured[0] is ai_msg - - async def test_callback_not_called_for_tool_messages(self): - from langchain_core.messages import ToolMessage - - captured: list[Any] = [] - tool_msg = ToolMessage(content="result", tool_call_id="c1", name="t") - - events = [("updates", {"tools": {"messages": [tool_msg]}})] - await _collect(convert_langgraph_to_agentex_events(_make_stream(events), on_final_ai_message=captured.append)) - assert captured == [] - - async def test_callback_receives_usage_metadata(self): - from langchain_core.messages import AIMessage - - captured: list[Any] = [] - usage = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15} - ai_msg = AIMessage(content="Answer.", usage_metadata=usage) - - events = [("updates", {"agent": {"messages": [ai_msg]}})] - await _collect(convert_langgraph_to_agentex_events(_make_stream(events), on_final_ai_message=captured.append)) - assert len(captured) == 1 - assert captured[0].usage_metadata == usage - - async def test_no_callback_is_noop(self): - from langchain_core.messages import AIMessage - - ai_msg = AIMessage(content="Hello!") - events = [("updates", {"agent": {"messages": [ai_msg]}})] - out = await _collect(convert_langgraph_to_agentex_events(_make_stream(events))) - assert isinstance(out, list) - - async def test_callback_called_multiple_times_for_multi_step(self): - from langchain_core.messages import AIMessage - - captured: list[Any] = [] - ai_msg_1 = AIMessage(content="Step 1") - ai_msg_2 = AIMessage(content="Step 2") - - events = [ - ("updates", {"agent": {"messages": [ai_msg_1]}}), - ("updates", {"agent": {"messages": [ai_msg_2]}}), - ] - await _collect(convert_langgraph_to_agentex_events(_make_stream(events), on_final_ai_message=captured.append)) - assert len(captured) == 2 - assert captured[0] is ai_msg_1 - assert captured[1] is ai_msg_2 - - async def test_callback_called_after_tool_call_events_yielded(self): - """The callback fires after all events for that AIMessage are yielded.""" - from langchain_core.messages import AIMessage - - yield_order: list[str] = [] - - async def _gen(): - tc = {"id": "c1", "name": "t", "args": {}} - ai_msg = AIMessage(content="", tool_calls=[tc]) - yield ("updates", {"agent": {"messages": [ai_msg]}}) - - def _cb(msg): - yield_order.append("callback") - - async for _ in convert_langgraph_to_agentex_events(_gen(), on_final_ai_message=_cb): - yield_order.append("event") - - # The tool call Full event is emitted before the callback fires - assert yield_order.index("event") < yield_order.index("callback") - - -# --------------------------------------------------------------------------- -# Unified sync path: LangGraphTurn + UnifiedEmitter.yield_turn -# -# Verifies the sync (HTTP ACP) delivery surface: -# 1. Passthrough: events from emitter.yield_turn(LangGraphTurn(stream)) equal -# LangGraphTurn(stream).events collected directly. -# 2. Span derivation: with trace_id + fake tracer, tool spans are derived from -# the event stream. -# --------------------------------------------------------------------------- - - -@dataclass -class _FakeTracingBackend: - spans_started: list[dict[str, Any]] = field(default_factory=list) - spans_ended: list[str] = field(default_factory=list) - - async def start_span(self, **kw) -> Any: - from agentex.types.span import Span - - sp = Span( - id=f"span-{len(self.spans_started) + 1}", - trace_id=kw.get("trace_id", "trace1"), - name=kw.get("name", ""), - start_time=datetime.now(tz=timezone.utc), - ) - self.spans_started.append(kw) - return sp - - async def end_span(self, *, trace_id: str, span: Any) -> None: - self.spans_ended.append(span.id if span else "") - - -class TestUnifiedSyncPathPassthrough: - async def test_yield_turn_events_equal_direct_events(self): - """Events from emitter.yield_turn(LangGraphTurn(stream)) must equal - LangGraphTurn(stream).events collected directly โ€” the emitter must not - add, drop, or reorder events in yield mode.""" - from langchain_core.messages import AIMessage, AIMessageChunk - - chunk = AIMessageChunk(content="Hello!") - ai_msg = AIMessage(content="Hello!") - - events_raw = [ - ("messages", (chunk, {})), - ("updates", {"agent": {"messages": [ai_msg]}}), - ] - - direct = [e async for e in LangGraphTurn(_make_stream(events_raw)).events] - - emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) - via_emitter = [e async for e in emitter.yield_turn(LangGraphTurn(_make_stream(events_raw)))] - - assert len(direct) == len(via_emitter), "yield_turn must not add or drop events relative to direct iteration" - for a, b in zip(direct, via_emitter, strict=True): - assert type(a) == type(b), f"Event type mismatch: {type(a).__name__} vs {type(b).__name__}" - - async def test_yield_turn_passes_all_event_types(self): - """Start, Delta, Done, Full โ€” each type is preserved.""" - from langchain_core.messages import AIMessage, AIMessageChunk - - chunk = AIMessageChunk(content="hi") - tc = {"id": "c1", "name": "t", "args": {}} - ai_msg = AIMessage(content="hi", tool_calls=[tc]) - - events_raw = [ - ("messages", (chunk, {})), - ("updates", {"agent": {"messages": [ai_msg]}}), - ] - emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) - out = [e async for e in emitter.yield_turn(LangGraphTurn(_make_stream(events_raw)))] - types = {type(e).__name__ for e in out} - # text chunk emits Start + Delta - assert "StreamTaskMessageStart" in types - assert "StreamTaskMessageDelta" in types - # tool call emits Full - assert "StreamTaskMessageFull" in types - - async def test_empty_stream_yields_no_events(self): - emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) - out = [e async for e in emitter.yield_turn(LangGraphTurn(_make_stream([])))] - assert out == [] - - -class TestUnifiedSyncPathSpanDerivation: - @pytest.fixture - def fake_tracer(self): - backend = _FakeTracingBackend() - tracer = SpanTracer( - trace_id="trace1", - parent_span_id=None, - task_id="t", - tracing=backend, # type: ignore[arg-type] - ) - return tracer, backend - - async def test_tool_span_derived_from_full_events(self, fake_tracer): - """SpanDeriver handles Full tool events for LangGraph. - - Full(ToolRequestContent) opens a tool span keyed by tool_call_id; - Full(ToolResponseContent) closes it, aligning LangGraph's Full-event - path with the Start+Done harnesses (pydantic-ai, openai-agents). - """ - from langchain_core.messages import AIMessage, ToolMessage - - tracer, backend = fake_tracer - tc = {"id": "c1", "name": "get_weather", "args": {"city": "Paris"}} - ai_msg = AIMessage(content="", tool_calls=[tc]) - tool_msg = ToolMessage(content="Sunny", tool_call_id="c1", name="get_weather") - - events_raw = [ - ("updates", {"agent": {"messages": [ai_msg]}}), - ("updates", {"tools": {"messages": [tool_msg]}}), - ] - - emitter = UnifiedEmitter(task_id="t", trace_id="trace1", parent_span_id=None, tracer=tracer) - _ = [e async for e in emitter.yield_turn(LangGraphTurn(_make_stream(events_raw)))] - - assert len(backend.spans_started) == 1, "Full(ToolRequestContent) opens one tool span" - started = backend.spans_started[0] - assert started["name"] == "get_weather" - assert started["input"] == {"city": "Paris"} - - async def test_no_spans_when_no_tool_calls(self, fake_tracer): - """yield_turn with tracer but no tool calls emits no spans.""" - from langchain_core.messages import AIMessage, AIMessageChunk - - tracer, backend = fake_tracer - chunk = AIMessageChunk(content="Hello!") - ai_msg = AIMessage(content="Hello!") - - events_raw = [ - ("messages", (chunk, {})), - ("updates", {"agent": {"messages": [ai_msg]}}), - ] - - emitter = UnifiedEmitter(task_id="t", trace_id="trace1", parent_span_id=None, tracer=tracer) - _ = [e async for e in emitter.yield_turn(LangGraphTurn(_make_stream(events_raw)))] - - assert backend.spans_started == [], "No tool spans when there are no tool calls" - - async def test_tracer_none_means_no_spans(self): - """With tracer=False, no spans should be emitted.""" - from langchain_core.messages import AIMessage, ToolMessage - - tc = {"id": "c1", "name": "t", "args": {}} - ai_msg = AIMessage(content="", tool_calls=[tc]) - tool_msg = ToolMessage(content="ok", tool_call_id="c1", name="t") - - events_raw = [ - ("updates", {"agent": {"messages": [ai_msg]}}), - ("updates", {"tools": {"messages": [tool_msg]}}), - ] - - emitter = UnifiedEmitter(task_id="t", trace_id="trace1", parent_span_id=None, tracer=False) - _ = [e async for e in emitter.yield_turn(LangGraphTurn(_make_stream(events_raw)))] - # No assertion on spans since tracer=False means emitter.tracer is None - assert emitter.tracer is None diff --git a/tests/lib/adk/test_langgraph_turn.py b/tests/lib/adk/test_langgraph_turn.py deleted file mode 100644 index 23aa34ba3..000000000 --- a/tests/lib/adk/test_langgraph_turn.py +++ /dev/null @@ -1,265 +0,0 @@ -"""Tests for LangGraphTurn and langgraph_usage_to_turn_usage.""" - -from __future__ import annotations - -import sys -from typing import Any - -import pytest - -from agentex.lib.core.harness.types import TurnUsage -from agentex.lib.adk._modules._langgraph_turn import LangGraphTurn, langgraph_usage_to_turn_usage - -# --------------------------------------------------------------------------- -# Remove conftest stubs so real langchain_core types are used -# --------------------------------------------------------------------------- - - -@pytest.fixture(autouse=True) -def _real_langchain_core(): - stub_keys = [k for k in sys.modules if k.startswith("langchain_core") or k.startswith("langgraph")] - saved = {k: sys.modules.pop(k) for k in stub_keys} - import importlib - - importlib.import_module("langchain_core.messages") - yield - sys.modules.update(saved) - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_stream(events: list[tuple[str, Any]]): - async def _gen(): - for e in events: - yield e - - return _gen() - - -async def _drain(turn: LangGraphTurn) -> list[Any]: - return [e async for e in turn.events] - - -# --------------------------------------------------------------------------- -# langgraph_usage_to_turn_usage -# --------------------------------------------------------------------------- - - -class TestLangGraphUsageToTurnUsage: - def test_none_usage_returns_empty_turn_usage(self): - result = langgraph_usage_to_turn_usage(None, model="gpt-4") - assert result == TurnUsage(model="gpt-4") - - def test_basic_token_fields_mapped(self): - usage = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15} - result = langgraph_usage_to_turn_usage(usage, model="gpt-4") - assert result.input_tokens == 10 - assert result.output_tokens == 5 - assert result.total_tokens == 15 - assert result.model == "gpt-4" - - def test_zero_output_tokens_preserved_not_coerced_to_none(self): - """Real zero counts must be preserved as 0, not None.""" - usage = {"input_tokens": 10, "output_tokens": 0, "total_tokens": 10} - result = langgraph_usage_to_turn_usage(usage, model=None) - assert result.output_tokens == 0 - - def test_cache_read_mapped_to_cached_input_tokens(self): - usage = { - "input_tokens": 20, - "output_tokens": 5, - "total_tokens": 25, - "input_token_details": {"cache_read": 8}, - } - result = langgraph_usage_to_turn_usage(usage, model=None) - assert result.cached_input_tokens == 8 - - def test_reasoning_mapped_to_reasoning_tokens(self): - usage = { - "input_tokens": 10, - "output_tokens": 15, - "total_tokens": 25, - "output_token_details": {"reasoning": 6}, - } - result = langgraph_usage_to_turn_usage(usage, model=None) - assert result.reasoning_tokens == 6 - - def test_missing_optional_fields_are_none(self): - usage = {"input_tokens": 5, "output_tokens": 3, "total_tokens": 8} - result = langgraph_usage_to_turn_usage(usage, model=None) - assert result.cached_input_tokens is None - assert result.reasoning_tokens is None - - def test_full_usage_object(self): - usage = { - "input_tokens": 100, - "output_tokens": 50, - "total_tokens": 150, - "input_token_details": {"cache_read": 30}, - "output_token_details": {"reasoning": 20}, - } - result = langgraph_usage_to_turn_usage(usage, model="claude-3-5-sonnet") - assert result == TurnUsage( - model="claude-3-5-sonnet", - input_tokens=100, - output_tokens=50, - total_tokens=150, - cached_input_tokens=30, - reasoning_tokens=20, - ) - - def test_model_none_is_preserved(self): - result = langgraph_usage_to_turn_usage({"input_tokens": 1}, model=None) - assert result.model is None - - def test_empty_input_token_details_does_not_crash(self): - usage = {"input_tokens": 5, "input_token_details": {}} - result = langgraph_usage_to_turn_usage(usage, model=None) - assert result.cached_input_tokens is None - - def test_empty_output_token_details_does_not_crash(self): - usage = {"output_tokens": 5, "output_token_details": {}} - result = langgraph_usage_to_turn_usage(usage, model=None) - assert result.reasoning_tokens is None - - -# --------------------------------------------------------------------------- -# LangGraphTurn -# --------------------------------------------------------------------------- - - -class TestLangGraphTurn: - async def test_events_yields_from_sync_converter(self): - from langchain_core.messages import AIMessage, AIMessageChunk - - chunk = AIMessageChunk(content="Hello!") - ai_msg = AIMessage(content="Hello!") - stream = _make_stream( - [ - ("messages", (chunk, {})), - ("updates", {"agent": {"messages": [ai_msg]}}), - ] - ) - turn = LangGraphTurn(stream) - events = await _drain(turn) - assert len(events) > 0 - - async def test_usage_is_empty_before_stream_consumed(self): - turn = LangGraphTurn(_make_stream([])) - # usage() before events consumed should return a default TurnUsage - usage = turn.usage() - assert isinstance(usage, TurnUsage) - - async def test_usage_captured_from_ai_message(self): - from langchain_core.messages import AIMessage - - usage_meta = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15} - ai_msg = AIMessage(content="Hi!", usage_metadata=usage_meta) - stream = _make_stream([("updates", {"agent": {"messages": [ai_msg]}})]) - turn = LangGraphTurn(stream, model="gpt-4") - await _drain(turn) - - usage = turn.usage() - assert usage.input_tokens == 10 - assert usage.output_tokens == 5 - assert usage.total_tokens == 15 - assert usage.model == "gpt-4" - - async def test_usage_accumulates_across_multiple_ai_messages(self): - """A multi-step turn (>1 LLM call) sums usage instead of keeping only the last.""" - from langchain_core.messages import AIMessage - - first = AIMessage( - content="thinking", - usage_metadata={ - "input_tokens": 10, - "output_tokens": 5, - "total_tokens": 15, - "input_token_details": {"cache_read": 2}, - "output_token_details": {"reasoning": 1}, - }, - ) - second = AIMessage( - content="answer", - usage_metadata={ - "input_tokens": 20, - "output_tokens": 7, - "total_tokens": 27, - "input_token_details": {"cache_read": 3}, - "output_token_details": {"reasoning": 4}, - }, - ) - stream = _make_stream( - [ - ("updates", {"agent": {"messages": [first]}}), - ("updates", {"agent": {"messages": [second]}}), - ] - ) - turn = LangGraphTurn(stream, model="gpt-4") - await _drain(turn) - - usage = turn.usage() - assert usage.input_tokens == 30 - assert usage.output_tokens == 12 - assert usage.total_tokens == 42 - assert usage.cached_input_tokens == 5 - assert usage.reasoning_tokens == 5 - assert usage.model == "gpt-4" - - async def test_usage_not_updated_when_no_usage_metadata(self): - from langchain_core.messages import AIMessage - - ai_msg = AIMessage(content="Hi!") - stream = _make_stream([("updates", {"agent": {"messages": [ai_msg]}})]) - turn = LangGraphTurn(stream, model="gpt-4") - await _drain(turn) - - usage = turn.usage() - assert usage == TurnUsage(model="gpt-4") - - async def test_usage_captures_cache_read_and_reasoning(self): - from langchain_core.messages import AIMessage - - usage_meta = { - "input_tokens": 100, - "output_tokens": 50, - "total_tokens": 150, - "input_token_details": {"cache_read": 30}, - "output_token_details": {"reasoning": 20}, - } - ai_msg = AIMessage(content="Result", usage_metadata=usage_meta) - stream = _make_stream([("updates", {"agent": {"messages": [ai_msg]}})]) - turn = LangGraphTurn(stream, model="claude-3-5-sonnet") - await _drain(turn) - - usage = turn.usage() - assert usage.cached_input_tokens == 30 - assert usage.reasoning_tokens == 20 - - async def test_harness_turn_protocol_conformance(self): - """LangGraphTurn satisfies the HarnessTurn Protocol.""" - from agentex.lib.core.harness.types import HarnessTurn - - turn = LangGraphTurn(_make_stream([])) - assert isinstance(turn, HarnessTurn), "LangGraphTurn must satisfy HarnessTurn Protocol" - - async def test_empty_stream_yields_no_events(self): - turn = LangGraphTurn(_make_stream([])) - events = await _drain(turn) - assert events == [] - - async def test_model_none_default(self): - turn = LangGraphTurn(_make_stream([])) - assert turn.usage().model is None - - async def test_model_passed_through_to_usage(self): - from langchain_core.messages import AIMessage - - ai_msg = AIMessage(content="ok", usage_metadata={"input_tokens": 1, "output_tokens": 0, "total_tokens": 1}) - stream = _make_stream([("updates", {"agent": {"messages": [ai_msg]}})]) - turn = LangGraphTurn(stream, model="my-model") - await _drain(turn) - assert turn.usage().model == "my-model" diff --git a/tests/lib/adk/test_messages_module.py b/tests/lib/adk/test_messages_module.py 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_openai_sync.py b/tests/lib/adk/test_openai_sync.py deleted file mode 100644 index de2a61db8..000000000 --- a/tests/lib/adk/test_openai_sync.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Tests for ``convert_openai_to_agentex_events`` and its helpers. - -Focused on three previously-broken behaviors on the sync OpenAI converter: - -- ``_safe_parse_arguments`` never raises on malformed/non-dict JSON (a bad - tool-args string must not abort the whole turn). -- Every streamed item โ€” text AND reasoning โ€” is closed with a matching - ``StreamTaskMessageDone`` (reasoning messages used to hang open). -- Each new text ``item_id`` gets a fresh index, so a final answer cannot - collide with the preceding reasoning message on reasoning-model streams. -""" - -import types as _types - -import pytest -from openai.types.responses import ResponseTextDeltaEvent, ResponseOutputItemDoneEvent -from openai.types.responses.response_output_message import ResponseOutputMessage -from openai.types.responses.response_reasoning_item import ResponseReasoningItem -from openai.types.responses.response_reasoning_text_delta_event import ResponseReasoningTextDeltaEvent - -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.lib.adk._modules._openai_sync import ( - _safe_parse_arguments, - convert_openai_to_agentex_events, -) - -# --------------------------------------------------------------------------- -# _safe_parse_arguments -# --------------------------------------------------------------------------- - - -def test_safe_parse_arguments_valid_dict_json(): - assert _safe_parse_arguments('{"a": 1}') == {"a": 1} - - -def test_safe_parse_arguments_empty_and_none(): - assert _safe_parse_arguments("") == {} - assert _safe_parse_arguments(None) == {} - - -def test_safe_parse_arguments_passthrough_dict(): - d = {"already": "dict"} - assert _safe_parse_arguments(d) is d - - -def test_safe_parse_arguments_malformed_preserved_not_raised(): - # A truncated / malformed payload must be preserved, never raise โ€” raising - # here would abort the whole turn before later output is delivered. - assert _safe_parse_arguments('{"a": ') == {"raw": '{"a": '} - - -def test_safe_parse_arguments_non_dict_json_wrapped(): - # Valid JSON that isn't an object is wrapped so the result stays a dict. - assert _safe_parse_arguments("[1, 2]") == {"value": [1, 2]} - assert _safe_parse_arguments("42") == {"value": 42} - - -def test_safe_parse_arguments_non_string_non_dict_always_returns_dict(): - # A provider tool may pass arguments as a list / scalar / SDK object rather - # than a JSON string. The result must still be a dict so ToolRequestContent - # (arguments: Dict[str, object]) accepts it instead of raising. - assert _safe_parse_arguments([1, 2]) == {"value": [1, 2]} - assert _safe_parse_arguments(7) == {"value": 7} - - class _Args: - def model_dump(self): - return {"q": "hi"} - - assert _safe_parse_arguments(_Args()) == {"q": "hi"} - - # An SDK object whose model_dump is not a dict still degrades to a dict. - class _BadDump: - def model_dump(self): - return ["not", "a", "dict"] - - bad = _BadDump() - assert _safe_parse_arguments(bad) == {"value": bad} - - -# --------------------------------------------------------------------------- -# convert_openai_to_agentex_events โ€” reasoning + text sequencing -# --------------------------------------------------------------------------- - - -def _raw(data): - return _types.SimpleNamespace(type="raw_response_event", data=data) - - -async def _stream(events): - for e in events: - yield e - - -async def _collect(events): - return [e async for e in convert_openai_to_agentex_events(_stream(events))] - - -@pytest.mark.asyncio -async def test_reasoning_item_emits_done(): - """A completed reasoning item must yield a matching Done (it used to be skipped).""" - events = [ - _raw( - ResponseReasoningTextDeltaEvent( - type="response.reasoning_text.delta", - item_id="r1", - content_index=0, - delta="thinking", - output_index=0, - sequence_number=1, - ) - ), - _raw( - ResponseOutputItemDoneEvent( - type="response.output_item.done", - item=ResponseReasoningItem(id="r1", type="reasoning", summary=[]), - output_index=0, - sequence_number=2, - ) - ), - ] - out = await _collect(events) - - starts = [e for e in out if isinstance(e, StreamTaskMessageStart)] - dones = [e for e in out if isinstance(e, StreamTaskMessageDone)] - assert len(starts) == 1 - # The reasoning message is now closed instead of hanging open. - assert [d.index for d in dones] == [starts[0].index] - - -@pytest.mark.asyncio -async def test_reasoning_then_text_use_distinct_indices(): - """Final answer text must not reuse the reasoning message's index.""" - events = [ - _raw( - ResponseReasoningTextDeltaEvent( - type="response.reasoning_text.delta", - item_id="r1", - content_index=0, - delta="thinking", - output_index=0, - sequence_number=1, - ) - ), - _raw( - ResponseOutputItemDoneEvent( - type="response.output_item.done", - item=ResponseReasoningItem(id="r1", type="reasoning", summary=[]), - output_index=0, - sequence_number=2, - ) - ), - _raw( - ResponseTextDeltaEvent( - type="response.output_text.delta", - item_id="t1", - content_index=0, - delta="answer", - output_index=1, - sequence_number=3, - logprobs=[], - ) - ), - _raw( - ResponseOutputItemDoneEvent( - type="response.output_item.done", - item=ResponseOutputMessage(id="t1", type="message", role="assistant", status="completed", content=[]), - output_index=1, - sequence_number=4, - ) - ), - ] - out = await _collect(events) - - starts = [e for e in out if isinstance(e, StreamTaskMessageStart)] - assert len(starts) == 2 - reasoning_index, text_index = starts[0].index, starts[1].index - assert reasoning_index != text_index - - # Text deltas route to the text index, not the reasoning index. - text_deltas = [e for e in out if isinstance(e, StreamTaskMessageDelta) and e.delta.type == "text"] - assert text_deltas and all(d.index == text_index for d in text_deltas) - - # Both messages are closed on their own index. - done_indices = sorted(e.index for e in out if isinstance(e, StreamTaskMessageDone)) - assert done_indices == sorted({reasoning_index, text_index}) diff --git a/tests/lib/adk/test_pydantic_ai_async.py b/tests/lib/adk/test_pydantic_ai_async.py deleted file mode 100644 index 4ab468152..000000000 --- a/tests/lib/adk/test_pydantic_ai_async.py +++ /dev/null @@ -1,776 +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_turn import stream_pydantic_ai_events - -TASK_ID = "task_test" - - -async def _aiter(events: list[Any]) -> AsyncIterator[Any]: - for e in events: - yield e - - -@dataclass -class FakeContext: - """In-memory stand-in for ``StreamingTaskMessageContext``. - - Records the order of updates and whether ``close()`` was called. The - helper drives this manually via ``__aenter__`` / ``close``, so we don't - use it as an ``async with`` โ€” we just track the calls. - """ - - initial_content: Any - task_message: TaskMessage - closed: bool = False - updates: list[StreamTaskMessageDelta] = field(default_factory=list) - - async def __aenter__(self) -> "FakeContext": - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb) -> bool: - await self.close() - return False - - async def stream_update(self, update: StreamTaskMessageDelta) -> None: - if self.closed: - raise AssertionError("stream_update called after close โ€” helper closed the wrong context") - self.updates.append(update) - - async def close(self) -> None: - self.closed = True - - -class FakeStreamingModule: - """Records every streaming context the helper opens, in order.""" - - def __init__(self) -> None: - self.contexts: list[FakeContext] = [] - - def streaming_task_message_context( - self, *, task_id: str, initial_content: Any, streaming_mode: str = "coalesced", created_at: Any = None - ) -> FakeContext: - tm = TaskMessage( - id=f"m{len(self.contexts) + 1}", - task_id=task_id, - content=initial_content, - streaming_status="IN_PROGRESS", - ) - ctx = FakeContext(initial_content=initial_content, task_message=tm) - self.contexts.append(ctx) - return ctx - - -class FakeMessagesModule: - """Records every ``adk.messages.create`` call.""" - - def __init__(self) -> None: - self.created: list[dict[str, Any]] = [] - - async def create(self, *, task_id: str, content: Any) -> TaskMessage: - self.created.append({"task_id": task_id, "content": content}) - return TaskMessage( - id=f"created-{len(self.created)}", - task_id=task_id, - content=content, - streaming_status="DONE", - ) - - -@pytest.fixture -def fake_adk(monkeypatch): - """Patches the lazy ``from agentex.lib import adk`` lookup inside the helper. - - Returns ``(streaming, messages)`` for assertions. - """ - from agentex.lib import adk as adk_module - - streaming = FakeStreamingModule() - messages = FakeMessagesModule() - monkeypatch.setattr(adk_module, "streaming", streaming) - monkeypatch.setattr(adk_module, "messages", messages) - return streaming, messages - - -def _text_deltas(ctx: FakeContext) -> list[str]: - out: list[str] = [] - for u in ctx.updates: - if isinstance(u.delta, TextDelta): - out.append(u.delta.text_delta or "") - return out - - -def _reasoning_deltas(ctx: FakeContext) -> list[str]: - out: list[str] = [] - for u in ctx.updates: - if isinstance(u.delta, ReasoningContentDelta): - out.append(u.delta.content_delta or "") - return out - - -class TestTextStreaming: - async def test_plain_text_opens_context_streams_deltas_and_closes( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - streaming, messages = fake_adk - events = [ - PartStartEvent(index=0, part=TextPart(content="")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="Hello")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta=", ")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="world!")), - PartEndEvent(index=0, part=TextPart(content="Hello, world!")), - ] - - final = await stream_pydantic_ai_events(_aiter(events), TASK_ID) - - assert len(streaming.contexts) == 1 - ctx = streaming.contexts[0] - assert isinstance(ctx.initial_content, TextContent) - assert ctx.initial_content.content == "" - assert _text_deltas(ctx) == ["Hello", ", ", "world!"] - assert ctx.closed is True, "PartEndEvent must close the streaming context" - assert messages.created == [], "Plain text must not emit standalone messages" - assert final == "Hello, world!" - - async def test_initial_content_in_part_start_is_streamed_as_delta( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - """Pydantic AI sometimes packs the first chunk inside ``PartStartEvent.part.content``. - - Agentex renders only Delta events as the message body, so the helper - must surface that initial chunk as a delta โ€” otherwise the first token - is invisible to the UI. - """ - streaming, _ = fake_adk - events = [ - PartStartEvent(index=0, part=TextPart(content="Already there")), - PartEndEvent(index=0, part=TextPart(content="Already there")), - ] - final = await stream_pydantic_ai_events(_aiter(events), TASK_ID) - - ctx = streaming.contexts[0] - assert _text_deltas(ctx) == ["Already there"] - assert final == "Already there" - - async def test_returns_only_last_text_segment_in_multi_step_run( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - """Matches the documented contract / the LangGraph async helper's behavior.""" - streaming, _ = fake_adk - events = [ - PartStartEvent(index=0, part=TextPart(content="")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="Looking up...")), - PartEndEvent(index=0, part=TextPart(content="Looking up...")), - PartStartEvent(index=0, part=TextPart(content="")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="It's sunny.")), - PartEndEvent(index=0, part=TextPart(content="It's sunny.")), - ] - final = await stream_pydantic_ai_events(_aiter(events), TASK_ID) - - assert len(streaming.contexts) == 2, "Two text parts โ†’ two streaming contexts" - assert all(ctx.closed for ctx in streaming.contexts) - assert _text_deltas(streaming.contexts[0]) == ["Looking up..."] - assert _text_deltas(streaming.contexts[1]) == ["It's sunny."] - assert final == "It's sunny." - - -class TestThinkingStreaming: - async def test_thinking_opens_reasoning_context_with_reasoning_deltas( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - streaming, _ = fake_adk - events = [ - PartStartEvent(index=0, part=ThinkingPart(content="")), - PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta="step 1...")), - PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta=" step 2.")), - PartEndEvent(index=0, part=ThinkingPart(content="step 1... step 2.")), - ] - await stream_pydantic_ai_events(_aiter(events), TASK_ID) - - ctx = streaming.contexts[0] - assert isinstance(ctx.initial_content, ReasoningContent) - assert _reasoning_deltas(ctx) == ["step 1...", " step 2."] - assert ctx.closed is True - - async def test_thinking_initial_content_is_streamed_as_delta( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - streaming, _ = fake_adk - events = [ - PartStartEvent(index=0, part=ThinkingPart(content="seed reasoning")), - PartEndEvent(index=0, part=ThinkingPart(content="seed reasoning")), - ] - await stream_pydantic_ai_events(_aiter(events), TASK_ID) - - ctx = streaming.contexts[0] - assert _reasoning_deltas(ctx) == ["seed reasoning"] - - async def test_empty_thinking_delta_is_skipped( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - streaming, _ = fake_adk - events = [ - PartStartEvent(index=0, part=ThinkingPart(content="")), - PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta=None)), - PartEndEvent(index=0, part=ThinkingPart(content="")), - ] - await stream_pydantic_ai_events(_aiter(events), TASK_ID) - - ctx = streaming.contexts[0] - assert _reasoning_deltas(ctx) == [], "Empty ThinkingPartDelta must not publish a zero-length reasoning delta" - assert ctx.closed is True - - -class TestToolCallEmission: - async def test_tool_call_opens_streaming_context_with_identity( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - """Tool requests are delivered as a streaming context (Start+Delta+Done). - - auto_send delivers streamed tool-request messages natively - (Start+ToolRequestDelta+Done). The streaming context is opened - at the Start event with the initial ToolRequestContent (tool_call_id + - name + empty arguments), argument tokens are streamed as deltas, and the - context is closed on Done. - - This test uses a realistic pydantic-ai event sequence: args arrive as a - PartDeltaEvent fragment (the way OpenAI/Anthropic actually stream JSON - tool-call arguments). - """ - from pydantic_ai.messages import ToolCallPartDelta - - from agentex.types.tool_request_delta import ToolRequestDelta - - streaming, messages = fake_adk - events = [ - PartStartEvent( - index=1, - part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="c1"), - ), - # Realistic: args arrive as delta tokens (JSON string fragments). - PartDeltaEvent( - index=1, - delta=ToolCallPartDelta(args_delta='{"city":"Paris"}'), - ), - PartEndEvent( - index=1, - part=ToolCallPart(tool_name="get_weather", args='{"city":"Paris"}', tool_call_id="c1"), - ), - ] - await stream_pydantic_ai_events(_aiter(events), TASK_ID) - - # AGX1-373: tool messages arrive via streaming_task_message_context. - assert messages.created == [], "adk.messages.create must not be called" - assert len(streaming.contexts) == 1, "tool_request opens a streaming context" - ctx = streaming.contexts[0] - assert ctx.closed is True - content = ctx.initial_content - assert isinstance(content, ToolRequestContent) - assert content.tool_call_id == "c1" - assert content.name == "get_weather" - assert content.author == "agent" - # Streamed shape: initial_content has empty args (args come via delta) - assert content.arguments == {} - # The arg delta is delivered as a stream_update - assert len(ctx.updates) == 1 - assert isinstance(ctx.updates[0].delta, ToolRequestDelta) - assert ctx.updates[0].delta.arguments_delta == '{"city":"Paris"}' - - async def test_tool_call_with_dict_args_passes_through( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - """When args arrive pre-populated as a dict in PartStart, they're in initial_content.""" - streaming, messages = fake_adk - events = [ - PartStartEvent( - index=0, - part=ToolCallPart(tool_name="search", args={"q": "weather"}, tool_call_id="c"), - ), - PartEndEvent( - index=0, - part=ToolCallPart(tool_name="search", args={"q": "weather"}, tool_call_id="c"), - ), - ] - await stream_pydantic_ai_events(_aiter(events), TASK_ID) - - # AGX1-373: tool messages via streaming_task_message_context - assert messages.created == [] - assert len(streaming.contexts) == 1 - # Dict args present at PartStart land directly in initial_content.arguments - assert streaming.contexts[0].initial_content.arguments == {"q": "weather"} - assert streaming.contexts[0].updates == [], "no delta for pre-populated dict args" - - async def test_tool_call_with_invalid_json_args_surfaces_raw( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - """Malformed JSON arg delta is surfaced as a ToolRequestDelta with the raw string. - - The argument delta is delivered as-is by auto_send; the client-side - accumulator or the streaming backend handles malformed JSON gracefully. - - Parts-manager invariant: PartEnd.part is the accumulated snapshot; real - pydantic-ai conveys args via PartStart + PartDeltaEvent, so a - PartStart(None)+PartEnd(json) with no delta is not realizable. - """ - from pydantic_ai.messages import ToolCallPartDelta - - from agentex.types.tool_request_delta import ToolRequestDelta - - streaming, messages = fake_adk - events = [ - PartStartEvent( - index=0, - part=ToolCallPart(tool_name="t", args=None, tool_call_id="c"), - ), - # Malformed JSON arrives as a delta token. - PartDeltaEvent( - index=0, - delta=ToolCallPartDelta(args_delta="not-json{"), - ), - PartEndEvent( - index=0, - part=ToolCallPart(tool_name="t", args="not-json{", tool_call_id="c"), - ), - ] - await stream_pydantic_ai_events(_aiter(events), TASK_ID) - - # AGX1-373: tool messages via streaming_task_message_context - assert messages.created == [] - assert len(streaming.contexts) == 1 - ctx = streaming.contexts[0] - # Initial content has empty args (args come via delta) - assert ctx.initial_content.arguments == {} - # The malformed JSON is surfaced verbatim in the ToolRequestDelta - assert len(ctx.updates) == 1 - assert isinstance(ctx.updates[0].delta, ToolRequestDelta) - assert ctx.updates[0].delta.arguments_delta == "not-json{" - - async def test_tool_call_with_none_args_defaults_to_empty_dict( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - streaming, messages = fake_adk - events = [ - PartStartEvent( - index=0, - part=ToolCallPart(tool_name="t", args=None, tool_call_id="c"), - ), - PartEndEvent( - index=0, - part=ToolCallPart(tool_name="t", args=None, tool_call_id="c"), - ), - ] - await stream_pydantic_ai_events(_aiter(events), TASK_ID) - - # AGX1-373: tool messages via streaming_task_message_context - assert messages.created == [] - assert len(streaming.contexts) == 1 - assert streaming.contexts[0].initial_content.arguments == {} - assert streaming.contexts[0].updates == [], "no delta when args are absent" - - -class TestToolResult: - async def test_tool_return_emits_full_tool_response_message( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - # AGX1-373: tool responses arrive via streaming_task_message_context - # (open+close pair), NOT via adk.messages.create. - streaming, messages = fake_adk - events = [ - FunctionToolResultEvent( - part=ToolReturnPart(tool_name="get_weather", content="Sunny, 72F", tool_call_id="c1"), - ), - ] - await stream_pydantic_ai_events(_aiter(events), TASK_ID) - - assert messages.created == [], "adk.messages.create must not be called after reimplementation" - assert len(streaming.contexts) == 1 - ctx = streaming.contexts[0] - assert ctx.closed is True - content = ctx.initial_content - assert isinstance(content, ToolResponseContent) - assert content.tool_call_id == "c1" - assert content.name == "get_weather" - assert content.content == "Sunny, 72F" - assert content.author == "agent" - assert ctx.updates == [], "open+close only โ€” no deltas for tool messages" - - async def test_tool_return_with_dict_content_preserves_structure( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - """Regression: structured tool results (dict / list / pydantic model) must - be preserved as structured data on ``ToolResponseContent.content``. - - The earlier ``str(content)`` path produced Python repr like - ``"{'temp': 72, 'sky': 'clear'}"`` โ€” invalid JSON, unreadable in the UI, - and divergent from the sync converter which uses ``_tool_return_content`` - to return dicts as-is. - """ - streaming, messages = fake_adk - events = [ - FunctionToolResultEvent( - part=ToolReturnPart(tool_name="t", content={"temp": 72, "sky": "clear"}, tool_call_id="c"), - ), - ] - await stream_pydantic_ai_events(_aiter(events), TASK_ID) - - # AGX1-373: tool messages via streaming_task_message_context - assert messages.created == [] - assert len(streaming.contexts) == 1 - out = streaming.contexts[0].initial_content.content - assert out == {"temp": 72, "sky": "clear"}, ( - f"Expected the dict to survive verbatim; got {out!r}. " - "If this is a Python repr string, the helper regressed to str(content)." - ) - - async def test_tool_return_with_pydantic_model_content_uses_model_dump( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - """Pydantic model tool results must be serialized via ``model_dump()``, - not ``str(model)``.""" - from pydantic import BaseModel - - class WeatherResult(BaseModel): - temp: int - sky: str - - streaming, messages = fake_adk - events = [ - FunctionToolResultEvent( - part=ToolReturnPart( - tool_name="t", - content=WeatherResult(temp=72, sky="clear"), - tool_call_id="c", - ), - ), - ] - await stream_pydantic_ai_events(_aiter(events), TASK_ID) - - # AGX1-373: tool messages via streaming_task_message_context - assert messages.created == [] - assert len(streaming.contexts) == 1 - out = streaming.contexts[0].initial_content.content - assert out == {"temp": 72, "sky": "clear"} - - async def test_retry_prompt_part_surfaces_as_tool_response( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - streaming, messages = fake_adk - events = [ - FunctionToolResultEvent( - part=RetryPromptPart( - content="bad arguments", - tool_name="get_weather", - tool_call_id="c1", - ), - ), - ] - await stream_pydantic_ai_events(_aiter(events), TASK_ID) - - # AGX1-373: tool messages via streaming_task_message_context - assert messages.created == [] - assert len(streaming.contexts) == 1 - content = streaming.contexts[0].initial_content - assert isinstance(content, ToolResponseContent) - assert content.tool_call_id == "c1" - # RetryPromptPart.content stringifies to the error description - assert "bad arguments" in str(content.content) - - -class TestContextLifecycle: - async def test_text_then_tool_then_text_uses_separate_contexts_in_order( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - """End-to-end multi-step shape: text โ†’ tool call โ†’ tool result โ†’ more text. - - AGX1-373 envelope change: tool messages now arrive via - streaming_task_message_context (open+close pairs) instead of - adk.messages.create. All four message types open streaming contexts. - """ - streaming, messages = fake_adk - events = [ - # First model response: text + tool call. - PartStartEvent(index=0, part=TextPart(content="")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="Looking up...")), - PartEndEvent(index=0, part=TextPart(content="Looking up...")), - PartStartEvent( - index=1, - part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="c1"), - ), - PartEndEvent( - index=1, - part=ToolCallPart(tool_name="get_weather", args="{}", tool_call_id="c1"), - ), - FunctionToolResultEvent( - part=ToolReturnPart(tool_name="get_weather", content="Sunny", tool_call_id="c1"), - ), - # Second model response: more text. - PartStartEvent(index=0, part=TextPart(content="")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="It's sunny.")), - PartEndEvent(index=0, part=TextPart(content="It's sunny.")), - ] - final = await stream_pydantic_ai_events(_aiter(events), TASK_ID) - - # AGX1-373: all 4 messages (text, tool_request, tool_response, text) - # arrive via streaming_task_message_context. - assert messages.created == [], "adk.messages.create must not be called after reimplementation" - assert len(streaming.contexts) == 4 - assert all(ctx.closed for ctx in streaming.contexts) - - text_ctxs = [ctx for ctx in streaming.contexts if isinstance(ctx.initial_content, TextContent)] - tool_req_ctxs = [ctx for ctx in streaming.contexts if isinstance(ctx.initial_content, ToolRequestContent)] - tool_resp_ctxs = [ctx for ctx in streaming.contexts if isinstance(ctx.initial_content, ToolResponseContent)] - assert len(text_ctxs) == 2 - assert len(tool_req_ctxs) == 1 - assert len(tool_resp_ctxs) == 1 - - assert _text_deltas(text_ctxs[0]) == ["Looking up..."] - assert _text_deltas(text_ctxs[1]) == ["It's sunny."] - - # Tool content is preserved verbatim. - assert tool_req_ctxs[0].initial_content.tool_call_id == "c1" - assert tool_resp_ctxs[0].initial_content.tool_call_id == "c1" - - # Tool contexts carry no deltas (open+close only). - assert tool_req_ctxs[0].updates == [] - assert tool_resp_ctxs[0].updates == [] - - assert final == "It's sunny." - - async def test_new_text_part_after_text_closes_previous( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - """Defensive: two text parts in a row (same response) must not bleed deltas across contexts.""" - streaming, _ = fake_adk - events = [ - PartStartEvent(index=0, part=TextPart(content="")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="A")), - PartStartEvent(index=1, part=TextPart(content="")), - PartDeltaEvent(index=1, delta=TextPartDelta(content_delta="B")), - PartEndEvent(index=1, part=TextPart(content="B")), - ] - await stream_pydantic_ai_events(_aiter(events), TASK_ID) - - assert len(streaming.contexts) == 2 - # First context was closed when the second TextPart started. - assert streaming.contexts[0].closed is True - assert _text_deltas(streaming.contexts[0]) == ["A"] - assert _text_deltas(streaming.contexts[1]) == ["B"] - - async def test_reasoning_then_text_closes_reasoning_context( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - """Switching from a thinking part to a text part must close the reasoning context.""" - streaming, _ = fake_adk - events = [ - PartStartEvent(index=0, part=ThinkingPart(content="")), - PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta="think")), - PartStartEvent(index=1, part=TextPart(content="")), - PartDeltaEvent(index=1, delta=TextPartDelta(content_delta="answer")), - PartEndEvent(index=1, part=TextPart(content="answer")), - ] - await stream_pydantic_ai_events(_aiter(events), TASK_ID) - - assert len(streaming.contexts) == 2 - # Reasoning context closed before text opened. - assert streaming.contexts[0].closed is True - assert isinstance(streaming.contexts[0].initial_content, ReasoningContent) - assert _reasoning_deltas(streaming.contexts[0]) == ["think"] - assert isinstance(streaming.contexts[1].initial_content, TextContent) - assert _text_deltas(streaming.contexts[1]) == ["answer"] - - async def test_tool_result_closes_any_open_streaming_context( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - """A tool result arriving while a text context is open must close that context first. - - AGX1-373: the tool response itself now also opens a streaming context - (open+close pair) rather than going through adk.messages.create. - """ - streaming, messages = fake_adk - events = [ - PartStartEvent(index=0, part=TextPart(content="")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="thinking")), - # No PartEndEvent โ€” provider sends the tool result while text is "live". - FunctionToolResultEvent( - part=ToolReturnPart(tool_name="t", content="ok", tool_call_id="c"), - ), - ] - await stream_pydantic_ai_events(_aiter(events), TASK_ID) - - assert streaming.contexts[0].closed is True, ( - "Helper must close any open streaming context before emitting a tool result message" - ) - # AGX1-373: tool response arrives via streaming_task_message_context - assert messages.created == [] - assert len(streaming.contexts) == 2 - assert isinstance(streaming.contexts[1].initial_content, ToolResponseContent) - - -class TestDeltaForOrphanIndexIgnored: - async def test_part_delta_without_matching_start_is_ignored( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - """A delta for an index we never saw a Start for must be a no-op, not a crash.""" - streaming, messages = fake_adk - events = [ - PartDeltaEvent(index=99, delta=TextPartDelta(content_delta="orphan")), - ] - final = await stream_pydantic_ai_events(_aiter(events), TASK_ID) - - assert streaming.contexts == [] - assert messages.created == [] - assert final == "" - - -class TestCleanupOnException: - async def test_open_contexts_are_closed_on_iterator_failure( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - """If the upstream Pydantic AI stream raises mid-flight, any open - streaming context must still be closed โ€” otherwise the Agentex - ``messages.update(..., streaming_status="DONE")`` call never runs and - the UI shows a perma-streaming message.""" - streaming, _ = fake_adk - - async def boom() -> AsyncIterator[Any]: - yield PartStartEvent(index=0, part=TextPart(content="")) - yield PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="partial")) - raise RuntimeError("upstream provider exploded") - - with pytest.raises(RuntimeError, match="upstream provider exploded"): - await stream_pydantic_ai_events(boom(), TASK_ID) - - assert streaming.contexts[0].closed is True - - -# --------------------------------------------------------------------------- -# Characterization test: lock the wire-level delivery shape for a representative -# pydantic-ai run (text + tool call + tool response + more text). -# -# Step 1 (CURRENT behavior): written against the original implementation. -# - Text/reasoning use adk.streaming.streaming_task_message_context. -# - Tool messages use adk.messages.create (FakeMessagesModule.created list). -# - Final text is the last text segment. -# -# Step 2 (POST-reimplementation on UnifiedEmitter / auto_send): -# The assertions in TestCharacterizeWireShapeNew (below) lock the new shape. -# Tool messages no longer go through adk.messages.create; they arrive via -# streaming_task_message_context open+close pairs (Start+Done envelope). -# This is the AGX1-373 accepted envelope change: logical content is identical. -# --------------------------------------------------------------------------- - - -class TestCharacterizeWireShape: - """Characterization tests: lock the wire-level delivery shape after reimplementation. - - Uses FakeStreamingModule + FakeMessagesModule (the existing fake pair). - - AGX1-373 shape (post-reimplementation on UnifiedEmitter / auto_send): - - Text/reasoning: streaming_task_message_context (open + deltas + close) - - Tool messages: streaming_task_message_context (open+close, no deltas) - - adk.messages.create is NOT called. - - Final text == last text segment only. - - This class was first written to characterize the OLD shape (adk.messages.create - for tool messages) and was updated post-reimplementation to reflect the new - delivery channel. The logical content is identical; only the channel changed. - """ - - async def test_text_tool_text_new_wire_shape( - self, fake_adk: tuple[FakeStreamingModule, FakeMessagesModule] - ) -> None: - """Representative run: text -> tool call -> tool response -> more text. - - Post-AGX1-373 delivery shape: - - Four streaming contexts: text, tool_request, tool_response, text. - - adk.messages.create NOT called. - - Final text == "It's sunny." (last segment only, matching the - multi-step convention). - """ - from pydantic_ai.messages import ToolReturnPart - - streaming, messages = fake_adk - events = [ - PartStartEvent(index=0, part=TextPart(content="")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="Looking up...")), - PartEndEvent(index=0, part=TextPart(content="Looking up...")), - PartStartEvent( - index=1, - part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="c1"), - ), - PartEndEvent( - index=1, - part=ToolCallPart(tool_name="get_weather", args="{}", tool_call_id="c1"), - ), - FunctionToolResultEvent( - part=ToolReturnPart(tool_name="get_weather", content="Sunny", tool_call_id="c1"), - ), - PartStartEvent(index=0, part=TextPart(content="")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="It's sunny.")), - PartEndEvent(index=0, part=TextPart(content="It's sunny.")), - ] - - final = await stream_pydantic_ai_events(_aiter(events), TASK_ID) - - assert final == "It's sunny.", "multi-step: only the last text segment is returned" - - # AGX1-373: all 4 messages arrive via streaming_task_message_context - assert messages.created == [] - assert len(streaming.contexts) == 4 - assert all(ctx.closed for ctx in streaming.contexts) - - content_types = [type(ctx.initial_content).__name__ for ctx in streaming.contexts] - assert content_types == [ - "TextContent", - "ToolRequestContent", - "ToolResponseContent", - "TextContent", - ] - - text_ctxs = [ctx for ctx in streaming.contexts if isinstance(ctx.initial_content, TextContent)] - tool_req_ctxs = [ctx for ctx in streaming.contexts if isinstance(ctx.initial_content, ToolRequestContent)] - tool_resp_ctxs = [ctx for ctx in streaming.contexts if isinstance(ctx.initial_content, ToolResponseContent)] - - assert _text_deltas(text_ctxs[0]) == ["Looking up..."] - assert _text_deltas(text_ctxs[1]) == ["It's sunny."] - assert tool_req_ctxs[0].initial_content.tool_call_id == "c1" - assert tool_req_ctxs[0].initial_content.name == "get_weather" - assert tool_req_ctxs[0].updates == [] - assert tool_resp_ctxs[0].initial_content.tool_call_id == "c1" - assert tool_resp_ctxs[0].initial_content.content == "Sunny" - assert tool_resp_ctxs[0].updates == [] diff --git a/tests/lib/adk/test_pydantic_ai_sync.py b/tests/lib/adk/test_pydantic_ai_sync.py deleted file mode 100644 index ac9986f2b..000000000 --- a/tests/lib/adk/test_pydantic_ai_sync.py +++ /dev/null @@ -1,639 +0,0 @@ -"""Tests for the sync Pydantic AI -> Agentex path. - -Covers: -- The bare converter ``convert_pydantic_ai_to_agentex_events`` (text/thinking/ - tool-call streaming and arg-delta handling). -- The unified sync (HTTP ACP) path ``UnifiedEmitter.yield_turn(PydanticAITurn(...))``: - * Passthrough: yield_turn events equal PydanticAITurn(stream).events - * Span derivation (tool + reasoning) with a fake tracing backend -""" - -from __future__ import annotations - -import json -import asyncio -from typing import Any, AsyncIterator - -import pytest -from pydantic_ai.run import AgentRunResult, AgentRunResultEvent -from pydantic_ai.messages import ( - TextPart, - PartEndEvent, - ThinkingPart, - ToolCallPart, - TextPartDelta, - PartDeltaEvent, - PartStartEvent, - ToolReturnPart, - RetryPromptPart, - FinalResultEvent, - ThinkingPartDelta, - ToolCallPartDelta, - FunctionToolCallEvent, - FunctionToolResultEvent, -) - -from agentex.lib.core.harness import UnifiedEmitter -from agentex.types.reasoning_content import ReasoningContent -from agentex.types.task_message_delta import TextDelta -from agentex.types.tool_request_delta import ToolRequestDelta -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.types.task_message_content import TextContent -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.types.reasoning_content_delta import ReasoningContentDelta -from agentex.lib.adk._modules._pydantic_ai_sync import ( - _args_delta_to_str, - convert_pydantic_ai_to_agentex_events, -) -from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn - -from ..core.harness._fakes import FakeTracing - - -async def _aiter(events: list[Any]) -> AsyncIterator[Any]: - for e in events: - yield e - - -async def _collect(stream: AsyncIterator[Any]) -> list[Any]: - return [e async for e in stream] - - -class TestArgsDeltaToStr: - def test_none(self): - assert _args_delta_to_str(None) == "" - - def test_string_passthrough(self): - assert _args_delta_to_str('{"k":') == '{"k":' - - def test_dict_dumps_json(self): - assert json.loads(_args_delta_to_str({"city": "Paris"})) == {"city": "Paris"} - - -class TestTextStreaming: - async def test_plain_text_emits_start_deltas_done(self): - events = [ - PartStartEvent(index=0, part=TextPart(content="")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="Hello")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta=", ")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="world!")), - PartEndEvent(index=0, part=TextPart(content="Hello, world!")), - ] - out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) - - assert len(out) == 5 - assert isinstance(out[0], StreamTaskMessageStart) - assert isinstance(out[0].content, TextContent) - assert out[0].content.content == "" - assert out[0].index == 0 - - for i, expected in enumerate(["Hello", ", ", "world!"], start=1): - assert isinstance(out[i], StreamTaskMessageDelta) - assert isinstance(out[i].delta, TextDelta) - assert out[i].delta.text_delta == expected - assert out[i].index == 0 - - assert isinstance(out[4], StreamTaskMessageDone) - assert out[4].index == 0 - - async def test_text_with_initial_content_emits_delta(self): - """Pydantic AI puts the first streaming chunk in PartStartEvent.part.content. - - The Agentex protocol only renders Delta events as the message body, so we - must emit the initial content as a Delta โ€” not in the Start โ€” otherwise - the first chunk disappears from the visible message. - """ - events = [ - PartStartEvent(index=0, part=TextPart(content="Already there")), - PartEndEvent(index=0, part=TextPart(content="Already there")), - ] - out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) - assert isinstance(out[0], StreamTaskMessageStart) - assert isinstance(out[0].content, TextContent) - assert out[0].content.content == "" - assert isinstance(out[1], StreamTaskMessageDelta) - assert isinstance(out[1].delta, TextDelta) - assert out[1].delta.text_delta == "Already there" - - -class TestThinkingStreaming: - async def test_thinking_emits_reasoning_deltas(self): - events = [ - PartStartEvent(index=0, part=ThinkingPart(content="")), - PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta="step 1...")), - PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta=" step 2.")), - PartEndEvent(index=0, part=ThinkingPart(content="step 1... step 2.")), - ] - out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) - - assert isinstance(out[0], StreamTaskMessageStart) - # Thinking content opens a ReasoningContent start, not a TextContent one, - # so the Start's content_type matches the ReasoningContentDelta updates - # that follow. Mismatched types here would render thinking as a plain - # text bubble (or break server-side accumulators) instead of a - # collapsible reasoning block. - assert isinstance(out[0].content, ReasoningContent) - assert isinstance(out[1], StreamTaskMessageDelta) - assert isinstance(out[1].delta, ReasoningContentDelta) - assert out[1].delta.content_delta == "step 1..." - assert out[1].delta.content_index == 0 - assert isinstance(out[2].delta, ReasoningContentDelta) - assert out[2].delta.content_delta == " step 2." - assert isinstance(out[3], StreamTaskMessageDone) - - async def test_thinking_with_initial_content_emits_delta(self): - events = [ - PartStartEvent(index=0, part=ThinkingPart(content="seed reasoning")), - ] - out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) - assert isinstance(out[0], StreamTaskMessageStart) - assert isinstance(out[1], StreamTaskMessageDelta) - assert isinstance(out[1].delta, ReasoningContentDelta) - assert out[1].delta.content_delta == "seed reasoning" - - async def test_thinking_delta_skipped_when_empty(self): - events = [ - PartStartEvent(index=0, part=ThinkingPart(content="")), - PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta=None)), - PartEndEvent(index=0, part=ThinkingPart(content="")), - ] - out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) - assert len(out) == 2 # Start + Done; no delta for None content - - -class TestToolCallStreaming: - async def test_tool_call_streamed_token_by_token(self): - """The headline use case: tool-call argument tokens streaming through to the client.""" - events = [ - PartStartEvent( - index=1, - part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="call_abc"), - ), - PartDeltaEvent( - index=1, - delta=ToolCallPartDelta(args_delta='{"city":', tool_call_id="call_abc"), - ), - PartDeltaEvent(index=1, delta=ToolCallPartDelta(args_delta='"Paris"}')), - PartEndEvent( - index=1, - part=ToolCallPart(tool_name="get_weather", args='{"city":"Paris"}', tool_call_id="call_abc"), - ), - ] - out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) - - assert len(out) == 4 - assert isinstance(out[0], StreamTaskMessageStart) - assert isinstance(out[0].content, ToolRequestContent) - assert out[0].content.tool_call_id == "call_abc" - assert out[0].content.name == "get_weather" - assert out[0].content.arguments == {} - - assert isinstance(out[1].delta, ToolRequestDelta) - assert out[1].delta.tool_call_id == "call_abc" - assert out[1].delta.name == "get_weather" - assert out[1].delta.arguments_delta == '{"city":' - - assert isinstance(out[2].delta, ToolRequestDelta) - assert out[2].delta.arguments_delta == '"Paris"}' - # tool_call_id is carried forward from the start even when the delta omits it - assert out[2].delta.tool_call_id == "call_abc" - - assert isinstance(out[3], StreamTaskMessageDone) - - async def test_tool_call_with_full_args_at_start(self): - """Some providers return a tool call in one shot โ€” args dict is set at start.""" - events = [ - PartStartEvent( - index=0, - part=ToolCallPart(tool_name="search", args={"query": "weather"}, tool_call_id="call_xyz"), - ), - PartEndEvent( - index=0, - part=ToolCallPart(tool_name="search", args={"query": "weather"}, tool_call_id="call_xyz"), - ), - ] - out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) - assert isinstance(out[0], StreamTaskMessageStart) - assert isinstance(out[0].content, ToolRequestContent) - assert out[0].content.arguments == {"query": "weather"} - # No deltas emitted โ€” args were already complete. - assert len(out) == 2 - assert isinstance(out[1], StreamTaskMessageDone) - - async def test_tool_call_with_full_args_string_at_start(self): - """When args is a complete JSON string at start, surface it as a single delta.""" - events = [ - PartStartEvent( - index=0, - part=ToolCallPart(tool_name="search", args='{"query":"weather"}', tool_call_id="call_z"), - ), - PartEndEvent( - index=0, - part=ToolCallPart(tool_name="search", args='{"query":"weather"}', tool_call_id="call_z"), - ), - ] - out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) - assert isinstance(out[0], StreamTaskMessageStart) - assert isinstance(out[0].content, ToolRequestContent) - assert out[0].content.arguments == {} - assert isinstance(out[1], StreamTaskMessageDelta) - assert isinstance(out[1].delta, ToolRequestDelta) - assert out[1].delta.arguments_delta == '{"query":"weather"}' - - async def test_tool_call_dict_args_delta_serialized(self): - events = [ - PartStartEvent( - index=0, - part=ToolCallPart(tool_name="t", args=None, tool_call_id="cid"), - ), - PartDeltaEvent( - index=0, - delta=ToolCallPartDelta(args_delta={"k": "v"}, tool_call_id="cid"), - ), - ] - out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) - assert json.loads(out[1].delta.arguments_delta) == {"k": "v"} - - async def test_tool_result_emits_full(self): - events = [ - PartStartEvent( - index=0, - part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="call_abc"), - ), - PartEndEvent( - index=0, - part=ToolCallPart(tool_name="get_weather", args="{}", tool_call_id="call_abc"), - ), - FunctionToolResultEvent( - part=ToolReturnPart(tool_name="get_weather", content="Sunny, 72F", tool_call_id="call_abc"), - ), - ] - out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) - - # Last event is the tool result -> Full ToolResponseContent - assert isinstance(out[-1], StreamTaskMessageFull) - assert isinstance(out[-1].content, ToolResponseContent) - assert out[-1].content.tool_call_id == "call_abc" - assert out[-1].content.name == "get_weather" - assert out[-1].content.content == "Sunny, 72F" - - async def test_tool_retry_prompt_surfaces_as_response(self): - events = [ - FunctionToolResultEvent( - part=RetryPromptPart( - content="bad arguments", - tool_name="get_weather", - tool_call_id="call_abc", - ), - ), - ] - out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) - assert isinstance(out[0], StreamTaskMessageFull) - assert isinstance(out[0].content, ToolResponseContent) - assert out[0].content.tool_call_id == "call_abc" - assert out[0].content.name == "get_weather" - # RetryPromptPart's content is the error message - assert out[0].content.content == "bad arguments" - - -class TestMultiStepRun: - async def test_text_then_tool_then_text_assigns_distinct_indices(self): - """A multi-step run: model emits text + tool call โ†’ tool runs โ†’ model emits more text. - - Pydantic AI restarts part indices at 0 for each new model response, so - the converter must assign fresh Agentex message indices. - """ - events = [ - # First model response: text at index 0, tool call at index 1 - PartStartEvent(index=0, part=TextPart(content="")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="Looking up...")), - PartEndEvent(index=0, part=TextPart(content="Looking up...")), - PartStartEvent( - index=1, - part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="c1"), - ), - PartDeltaEvent(index=1, delta=ToolCallPartDelta(args_delta="{}")), - PartEndEvent(index=1, part=ToolCallPart(tool_name="get_weather", args="{}", tool_call_id="c1")), - FunctionToolResultEvent( - part=ToolReturnPart(tool_name="get_weather", content="Sunny", tool_call_id="c1"), - ), - # Second model response: text restarts at index 0 - PartStartEvent(index=0, part=TextPart(content="")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="It's sunny.")), - PartEndEvent(index=0, part=TextPart(content="It's sunny.")), - ] - out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) - - # Pull every Start/Full event and check their assigned message indices - anchors = [e for e in out if isinstance(e, (StreamTaskMessageStart, StreamTaskMessageFull))] - indices = [e.index for e in anchors] - assert indices == [0, 1, 2, 3], ( - f"Expected 4 distinct, monotonic message indices for: text1, tool_call, tool_result, text2 โ€” got {indices}" - ) - - # And the second text's deltas should target the second text's message index. - text2_start = anchors[3] - text2_deltas = [ - e - for e in out - if isinstance(e, StreamTaskMessageDelta) and isinstance(e.delta, TextDelta) and e.index == text2_start.index - ] - assert len(text2_deltas) == 1 - text2_delta = text2_deltas[0].delta - assert isinstance(text2_delta, TextDelta) - assert text2_delta.text_delta == "It's sunny." - - -class TestIgnoredEvents: - async def test_function_tool_call_event_is_ignored(self): - """FunctionToolCallEvent is redundant with PartStart+Delta+End and should be skipped.""" - events = [ - PartStartEvent( - index=0, - part=ToolCallPart(tool_name="t", args=None, tool_call_id="c"), - ), - FunctionToolCallEvent( - part=ToolCallPart(tool_name="t", args="{}", tool_call_id="c"), - ), - PartEndEvent(index=0, part=ToolCallPart(tool_name="t", args="{}", tool_call_id="c")), - ] - out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) - # Start + Done only โ€” no event from FunctionToolCallEvent - assert len(out) == 2 - assert isinstance(out[0], StreamTaskMessageStart) - assert isinstance(out[1], StreamTaskMessageDone) - - async def test_final_result_event_ignored(self): - events = [ - FinalResultEvent(tool_name=None, tool_call_id=None), - ] - out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) - assert out == [] - - async def test_unknown_part_index_delta_skipped(self): - events = [ - PartDeltaEvent(index=99, delta=TextPartDelta(content_delta="orphan")), - ] - out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) - assert out == [] - - -class TestStartingTextMatchesAuthor: - """Sanity check that all emitted content is authored by the agent.""" - - @pytest.mark.parametrize( - "events", - [ - [PartStartEvent(index=0, part=TextPart(content=""))], - [PartStartEvent(index=0, part=ThinkingPart(content=""))], - [ - PartStartEvent( - index=0, - part=ToolCallPart(tool_name="t", args=None, tool_call_id="c"), - ) - ], - [ - FunctionToolResultEvent( - part=ToolReturnPart(tool_name="t", content="ok", tool_call_id="c"), - ) - ], - ], - ) - async def test_author_is_agent(self, events: list[Any]): - out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) - for e in out: - content = getattr(e, "content", None) - if content is not None and hasattr(content, "author"): - assert content.author == "agent" - - -class TestOnResultCallback: - """on_result callback: captures the terminal AgentRunResultEvent without - altering streaming output.""" - - def _make_result_event(self, output: Any = "hello") -> AgentRunResultEvent: - result = AgentRunResult(output=output, _output_tool_name=None) - return AgentRunResultEvent(result=result) - - async def test_callback_invoked_once_with_result_event(self): - """on_result is called exactly once, with the AgentRunResultEvent.""" - captured: list[AgentRunResultEvent] = [] - - def on_result(event: AgentRunResultEvent) -> None: - captured.append(event) - - result_event = self._make_result_event("the answer") - events = [result_event] - await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events), on_result=on_result)) - - assert len(captured) == 1 - assert captured[0] is result_event - assert captured[0].result.output == "the answer" - - async def test_streaming_output_unchanged_with_callback(self): - """Yielded StreamTaskMessage* sequence is identical whether on_result is set or not.""" - result_event = self._make_result_event() - events = [ - PartStartEvent(index=0, part=TextPart(content="")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hi")), - PartEndEvent(index=0, part=TextPart(content="hi")), - result_event, - ] - - captured: list[AgentRunResultEvent] = [] - out_with = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events), on_result=captured.append)) - out_without = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) - - assert len(out_with) == len(out_without) - for a, b in zip(out_with, out_without): - assert type(a) is type(b) - assert a.model_dump() == b.model_dump() - assert len(captured) == 1 - - async def test_no_callback_no_error(self): - """AgentRunResultEvent is silently ignored when on_result is None.""" - result_event = self._make_result_event() - events = [result_event] - out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events))) - assert out == [] - - async def test_async_callback_is_awaited(self): - """An async on_result callable is properly awaited. - - The callback suspends (``await asyncio.sleep(0)``) before recording its - side effect, so ``awaited`` is only populated if the converter actually - awaits the returned coroutine โ€” distinguishing "awaited" from - "called-but-not-awaited." - """ - awaited: list[AgentRunResultEvent] = [] - - async def on_result_async(event: AgentRunResultEvent) -> None: - await asyncio.sleep(0) - awaited.append(event) - - result_event = self._make_result_event("async_output") - events = [result_event] - await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events), on_result=on_result_async)) - - assert len(awaited) == 1 - assert awaited[0].result.output == "async_output" - - -# --------------------------------------------------------------------------- -# Unified sync path: PydanticAITurn + UnifiedEmitter.yield_turn -# -# Exercises the path documented in _pydantic_ai_sync.py under -# "Recommended: unified surface": -# - events forwarded by yield_turn equal PydanticAITurn(stream).events (passthrough) -# - with a trace context + fake tracing backend, tool / reasoning spans are derived -# --------------------------------------------------------------------------- - - -class TestUnifiedSyncPathPassthrough: - """The events forwarded by yield_turn are identical to PydanticAITurn.events.""" - - async def test_text_stream_passthrough(self): - raw_events = [ - PartStartEvent(index=0, part=TextPart(content="")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hello")), - PartEndEvent(index=0, part=TextPart(content="hello")), - ] - - turn_a = PydanticAITurn(_aiter(raw_events), model="openai:gpt-4o") - direct = await _collect(turn_a.events) - - turn_b = PydanticAITurn(_aiter(raw_events), model="openai:gpt-4o") - emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) - via_emitter = await _collect(emitter.yield_turn(turn_b)) - - assert len(via_emitter) == len(direct) - for a, b in zip(via_emitter, direct): - assert type(a) is type(b) - assert a.model_dump() == b.model_dump() - - async def test_tool_call_stream_passthrough(self): - raw_events = [ - PartStartEvent(index=0, part=ToolCallPart(tool_name="Bash", args=None, tool_call_id="c1")), - PartDeltaEvent(index=0, delta=ToolCallPartDelta(args_delta='{"cmd":"ls"}')), - PartEndEvent( - index=0, - part=ToolCallPart(tool_name="Bash", args='{"cmd":"ls"}', tool_call_id="c1"), - ), - ] - - turn_a = PydanticAITurn(_aiter(raw_events), model="openai:gpt-4o") - direct = await _collect(turn_a.events) - - turn_b = PydanticAITurn(_aiter(raw_events), model="openai:gpt-4o") - emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) - via_emitter = await _collect(emitter.yield_turn(turn_b)) - - assert len(via_emitter) == len(direct) - for a, b in zip(via_emitter, direct): - assert type(a) is type(b) - assert a.model_dump() == b.model_dump() - - -class TestUnifiedSyncPathSpanDerivation: - """With trace context + fake tracing, spans are derived from the stream.""" - - async def test_tool_span_opened_and_closed(self): - """A tool call produces start_span + end_span on the fake tracing backend.""" - tool_events = [ - PartStartEvent( - index=0, - part=ToolCallPart(tool_name="Bash", args={"cmd": "ls"}, tool_call_id="call_1"), - ), - PartEndEvent( - index=0, - part=ToolCallPart(tool_name="Bash", args='{"cmd":"ls"}', tool_call_id="call_1"), - ), - FunctionToolResultEvent( - part=ToolReturnPart(tool_name="Bash", content="files", tool_call_id="call_1"), - ), - ] - - fake = FakeTracing() - turn = PydanticAITurn(_aiter(tool_events), model="openai:gpt-4o") - emitter = UnifiedEmitter(task_id="t", trace_id="tr", parent_span_id="p", tracing=fake) - - events = await _collect(emitter.yield_turn(turn)) - - assert len(events) >= 2, "at least Start(tool) + Done + Full(response)" - assert len(fake.started) == 1, "one tool span opened" - assert len(fake.ended) == 1, "one tool span closed" - span_name, parent_id, span_input = fake.started[0] - assert span_name == "Bash" - assert parent_id == "p" - closed_name, closed_output = fake.ended[0] - assert closed_name == "Bash" - - async def test_reasoning_span_opened_and_closed(self): - """A thinking/reasoning block produces start_span + end_span.""" - reasoning_events = [ - PartStartEvent(index=0, part=ThinkingPart(content="")), - PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta="let me think")), - PartEndEvent(index=0, part=ThinkingPart(content="let me think")), - ] - - fake = FakeTracing() - turn = PydanticAITurn(_aiter(reasoning_events), model="openai:gpt-4o") - emitter = UnifiedEmitter(task_id="t", trace_id="tr", parent_span_id="p", tracing=fake) - - await _collect(emitter.yield_turn(turn)) - - assert len(fake.started) == 1, "one reasoning span opened" - assert len(fake.ended) == 1, "one reasoning span closed" - span_name, parent_id, _ = fake.started[0] - assert span_name == "reasoning" - assert parent_id == "p" - - async def test_no_trace_id_means_no_spans(self): - """When trace_id is None, no spans are derived even with a fake tracing backend.""" - raw_events = [ - PartStartEvent( - index=0, - part=ToolCallPart(tool_name="Bash", args={"cmd": "ls"}, tool_call_id="c2"), - ), - PartEndEvent( - index=0, - part=ToolCallPart(tool_name="Bash", args='{"cmd":"ls"}', tool_call_id="c2"), - ), - ] - - fake = FakeTracing() - turn = PydanticAITurn(_aiter(raw_events), model="openai:gpt-4o") - emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None, tracing=fake) - - await _collect(emitter.yield_turn(turn)) - - assert fake.started == [], "no spans when trace_id is absent" - assert fake.ended == [] - - async def test_tracer_false_suppresses_spans_even_with_trace_id(self): - """tracer=False disables span derivation regardless of trace_id.""" - raw_events = [ - PartStartEvent( - index=0, - part=ToolCallPart(tool_name="Bash", args={"cmd": "ls"}, tool_call_id="c3"), - ), - PartEndEvent( - index=0, - part=ToolCallPart(tool_name="Bash", args='{"cmd":"ls"}', tool_call_id="c3"), - ), - ] - - fake = FakeTracing() - turn = PydanticAITurn(_aiter(raw_events), model="openai:gpt-4o") - emitter = UnifiedEmitter(task_id="t", trace_id="tr", parent_span_id="p", tracer=False, tracing=fake) - - await _collect(emitter.yield_turn(turn)) - - assert fake.started == [] - assert fake.ended == [] diff --git a/tests/lib/adk/test_pydantic_ai_turn.py b/tests/lib/adk/test_pydantic_ai_turn.py deleted file mode 100644 index c57251db6..000000000 --- a/tests/lib/adk/test_pydantic_ai_turn.py +++ /dev/null @@ -1,276 +0,0 @@ -"""Tests for PydanticAITurn and pydantic_ai_usage_to_turn_usage.""" - -from __future__ import annotations - -from typing import Any, AsyncIterator - -from pydantic_ai.run import AgentRunResult, AgentRunResultEvent -from pydantic_ai.usage import RunUsage -from pydantic_ai.messages import ( - TextPart, - PartEndEvent, - TextPartDelta, - PartDeltaEvent, - PartStartEvent, -) - -from agentex.lib.core.harness import HarnessTurn -from agentex.lib.adk._modules._pydantic_ai_turn import ( - PydanticAITurn, - pydantic_ai_usage_to_turn_usage, -) - - -async def _aiter(events: list[Any]) -> AsyncIterator[Any]: - for e in events: - yield e - - -async def _collect(stream: AsyncIterator[Any]) -> list[Any]: - return [e async for e in stream] - - -def _make_result_event(output: Any = "done", usage: RunUsage | None = None) -> AgentRunResultEvent: - result = AgentRunResult(output=output, _output_tool_name=None) - if usage is not None: - result._state.usage = usage - return AgentRunResultEvent(result=result) - - -class TestUsageNormalization: - def test_usage_normalization_maps_fields(self): - """Real RunUsage fields map correctly onto TurnUsage.""" - usage = RunUsage( - requests=3, - input_tokens=200, - output_tokens=80, - cache_read_tokens=25, - ) - turn_usage = pydantic_ai_usage_to_turn_usage(usage, model="openai:gpt-4o") - - assert turn_usage.model == "openai:gpt-4o" - assert turn_usage.input_tokens == 200 - assert turn_usage.output_tokens == 80 - assert turn_usage.num_llm_calls == 3 - - def test_total_tokens_is_computed(self): - """RunUsage.total_tokens is a computed property; we surface it correctly.""" - usage = RunUsage(input_tokens=100, output_tokens=50) - turn_usage = pydantic_ai_usage_to_turn_usage(usage, model="openai:gpt-4o") - assert turn_usage.total_tokens == 150 - - def test_cache_read_tokens_mapped_to_cached_input_tokens(self): - usage = RunUsage(input_tokens=100, output_tokens=50, cache_read_tokens=20) - turn_usage = pydantic_ai_usage_to_turn_usage(usage, model="openai:gpt-4o") - assert turn_usage.cached_input_tokens == 20 - - def test_none_model(self): - """model=None is preserved.""" - usage = RunUsage() - turn_usage = pydantic_ai_usage_to_turn_usage(usage, model=None) - assert turn_usage.model is None - - def test_all_zero_usage_preserves_real_zeros(self): - """An all-zero RunUsage maps real 0s through (not None). - - RunUsage token fields are ints defaulting to 0. A 0 is a genuine - value (e.g. a cache-hit with 0 output tokens), not "unknown", so it - must survive normalization as 0 rather than being coerced to None. - """ - usage = RunUsage() - turn_usage = pydantic_ai_usage_to_turn_usage(usage, model="openai:gpt-4o") - assert turn_usage.num_llm_calls == 0 - assert turn_usage.input_tokens == 0 - assert turn_usage.output_tokens == 0 - assert turn_usage.cached_input_tokens == 0 - assert turn_usage.total_tokens == 0 - - def test_missing_field_degrades_to_none(self): - """A usage object MISSING a field maps that field to None (defensive getattr). - - Guards the version-rename guarantee: if pydantic-ai renames a field, - the absent attribute degrades to None rather than raising. - """ - - class StubUsage: - requests = 2 - input_tokens = 100 - # no output_tokens / cache_read_tokens / total_tokens attributes - - turn_usage = pydantic_ai_usage_to_turn_usage(StubUsage(), model="openai:gpt-4o") - assert turn_usage.num_llm_calls == 2 - assert turn_usage.input_tokens == 100 - assert turn_usage.output_tokens is None - assert turn_usage.cached_input_tokens is None - assert turn_usage.total_tokens is None - - -class TestPydanticAITurn: - async def test_turn_satisfies_harness_turn_protocol(self): - """PydanticAITurn is structurally compatible with HarnessTurn.""" - turn = PydanticAITurn(_aiter([]), model="openai:gpt-4o") - assert isinstance(turn, HarnessTurn) - - async def test_usage_before_exhaustion_returns_default(self): - """usage() before iterating events returns default TurnUsage (model set, tokens None).""" - result_event = _make_result_event(usage=RunUsage(requests=1, input_tokens=100, output_tokens=40)) - events = [result_event] - turn = PydanticAITurn(_aiter(events), model="openai:gpt-4o") - - # Do NOT exhaust events โ€” check usage pre-run - pre_usage = turn.usage() - assert pre_usage.model == "openai:gpt-4o" - assert pre_usage.input_tokens is None - assert pre_usage.output_tokens is None - assert pre_usage.num_llm_calls is None - - async def test_turn_events_and_usage(self): - """Driving events to exhaustion populates usage from the terminal event.""" - known_usage = RunUsage( - requests=2, - input_tokens=300, - output_tokens=120, - cache_read_tokens=30, - ) - result_event = _make_result_event(usage=known_usage) - events = [ - PartStartEvent(index=0, part=TextPart(content="")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hi")), - PartEndEvent(index=0, part=TextPart(content="hi")), - result_event, - ] - turn = PydanticAITurn(_aiter(events), model="openai:gpt-4o") - - collected = await _collect(turn.events) - - # Events match bare converter output (Start + Delta + Done = 3 events) - assert len(collected) == 3 - - # Usage is populated after exhaustion - usage = turn.usage() - assert usage.model == "openai:gpt-4o" - assert usage.input_tokens == 300 - assert usage.output_tokens == 120 - assert usage.cached_input_tokens == 30 - assert usage.num_llm_calls == 2 - assert usage.total_tokens == 420 - - async def test_events_match_bare_converter(self): - """Yielded events are identical to bare convert_pydantic_ai_to_agentex_events output.""" - from agentex.lib.adk._modules._pydantic_ai_sync import convert_pydantic_ai_to_agentex_events - - text_events = [ - PartStartEvent(index=0, part=TextPart(content="")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="Hello")), - PartEndEvent(index=0, part=TextPart(content="Hello")), - ] - - turn = PydanticAITurn(_aiter(text_events), model="openai:gpt-4o") - turn_out = await _collect(turn.events) - - bare_out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(text_events))) - - assert len(turn_out) == len(bare_out) - for a, b in zip(turn_out, bare_out): - assert type(a) is type(b) - assert a.model_dump() == b.model_dump() - - async def test_usage_captured_via_real_usage_accessor(self): - """Drive the turn through the REAL ``result.usage`` property accessor. - - The production code reads ``getattr(run_result, "usage", None)``, which - on this pydantic-ai version resolves the ``_DeprecatedCallableRunUsage`` - property (NOT ``_state.usage`` directly). This asserts that the real - accessor path the converter uses captures the run usage. Constructing - the event without our test's ``_state`` shortcut: we set ``_state.usage`` - only because that is the sole supported way to seed an - ``AgentRunResult``, but we then assert capture happens through the - public ``.usage`` attribute access (verified below). - """ - known_usage = RunUsage(requests=4, input_tokens=512, output_tokens=64) - result = AgentRunResult(output="done", _output_tool_name=None) - result._state.usage = known_usage - result_event = AgentRunResultEvent(result=result) - - # Sanity: the value is reachable via the real public accessor the - # production code uses (not just via the private _state). The - # _DeprecatedCallableRunUsage property wraps the value, so compare by - # equality rather than identity. - accessed = getattr(result_event.result, "usage", None) - assert accessed is not None - assert accessed.input_tokens == 512 - assert accessed.requests == 4 - - events = [ - PartStartEvent(index=0, part=TextPart(content="")), - PartEndEvent(index=0, part=TextPart(content="")), - result_event, - ] - turn = PydanticAITurn(_aiter(events), model="anthropic:claude-3-5-sonnet") - await _collect(turn.events) - - usage = turn.usage() - assert usage.model == "anthropic:claude-3-5-sonnet" - assert usage.input_tokens == 512 - assert usage.output_tokens == 64 - assert usage.num_llm_calls == 4 - - async def test_no_usage_event_leaves_default_usage(self): - """If the stream has no AgentRunResultEvent, usage() returns the default (tokens None).""" - events = [ - PartStartEvent(index=0, part=TextPart(content="")), - PartEndEvent(index=0, part=TextPart(content="")), - ] - turn = PydanticAITurn(_aiter(events), model="openai:gpt-4o") - await _collect(turn.events) - - usage = turn.usage() - assert usage.model == "openai:gpt-4o" - assert usage.input_tokens is None - assert usage.num_llm_calls is None - - -class TestToolRequestStreaming: - """PydanticAITurn.events equals the bare converter output unconditionally. - - The foundation auto_send delivers Start+ToolRequestDelta+Done natively, so - no coalescing is needed on either channel. - """ - - async def test_events_match_bare_converter_for_streamed_tool_call(self): - """PydanticAITurn yields a ToolRequestDelta for a streamed-args tool call - โ€” i.e. it is byte-for-byte the bare converter output, preserving - argument-token streaming on the sync/yield channel.""" - from pydantic_ai.messages import ToolCallPart, ToolCallPartDelta - - from agentex.types.tool_request_delta import ToolRequestDelta - from agentex.types.task_message_update import StreamTaskMessageDelta - from agentex.lib.adk._modules._pydantic_ai_sync import convert_pydantic_ai_to_agentex_events - - tool_events = [ - PartStartEvent(index=0, part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="c1")), - PartDeltaEvent(index=0, delta=ToolCallPartDelta(args_delta='{"city":"Paris"}')), - PartEndEvent( - index=0, - part=ToolCallPart(tool_name="get_weather", args='{"city":"Paris"}', tool_call_id="c1"), - ), - ] - - turn = PydanticAITurn(_aiter(tool_events), model="openai:gpt-4o") - turn_out = await _collect(turn.events) - - bare_out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(tool_events))) - - # Turn is identical to the bare converter. - assert len(turn_out) == len(bare_out) - for a, b in zip(turn_out, bare_out): - assert type(a) is type(b) - assert a.model_dump() == b.model_dump() - - # The arg-streaming delta is present. - deltas = [ - e for e in turn_out if isinstance(e, StreamTaskMessageDelta) and isinstance(e.delta, ToolRequestDelta) - ] - assert len(deltas) == 1, "streamed tool-call args must surface as a ToolRequestDelta" - assert isinstance(deltas[0].delta, ToolRequestDelta) - assert deltas[0].delta.arguments_delta == '{"city":"Paris"}' diff --git a/tests/lib/adk/test_state_service.py b/tests/lib/adk/test_state_service.py deleted file mode 100644 index 43b53ff39..000000000 --- a/tests/lib/adk/test_state_service.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Tests for StateService forwarding task_id/agent_id to the SDK client. - -Regression guard for the 0.13.0 incident: the generated client dropped -task_id/agent_id from states.update(), so the ADK stopped sending them in the -body and every state write 422'd against backends predating scale-agentex#278. -""" - -from __future__ import annotations - -from datetime import datetime, timezone -from unittest.mock import Mock, AsyncMock - -from agentex.types.state import State -from agentex.lib.core.services.adk.state import StateService - -_TS = datetime(2026, 5, 13, 18, 30, 0, tzinfo=timezone.utc) - - -def _make_state() -> State: - return State( - id="s1", - agent_id="a1", - task_id="t1", - state={"k": "v"}, - created_at=_TS, - ) - - -def _mock_span(): - span = Mock() - span.output = None - - async def __aenter__(_self): - return span - - async def __aexit__(_self, *args): - return None - - span.__aenter__ = __aenter__ - span.__aexit__ = __aexit__ - return span - - -def _make_service() -> tuple[AsyncMock, StateService]: - client = AsyncMock() - tracer = Mock() - trace = Mock() - trace.span.return_value = _mock_span() - tracer.trace.return_value = trace - return client, StateService(agentex_client=client, tracer=tracer) - - -class TestUpdateStateSendsParentIdentifiers: - async def test_task_id_and_agent_id_sent_in_body(self) -> None: - client, svc = _make_service() - client.states.update.return_value = _make_state() - - await svc.update_state( - state_id="s1", - task_id="t1", - agent_id="a1", - state={"k": "v"}, - ) - - kwargs = client.states.update.call_args.kwargs - assert kwargs["state_id"] == "s1" - # task_id/agent_id must ride in extra_body โ€” the generated client dropped - # them from the typed signature, but old backends still require them. - assert kwargs["extra_body"] == {"task_id": "t1", "agent_id": "a1"} diff --git a/tests/lib/adk/test_tasks_activities.py b/tests/lib/adk/test_tasks_activities.py 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 00e4aae65..000000000 --- a/tests/lib/adk/test_tracing_module.py +++ /dev/null @@ -1,418 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timezone -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from temporalio.exceptions import ActivityError - -import agentex.lib.adk._modules.tracing as _tracing_mod -from agentex.types.span import Span -from agentex.lib.core.harness.types import TurnUsage -from agentex.lib.adk._modules.tracing import TurnSpan, TracingModule -from agentex.lib.core.services.adk.tracing import TracingService - - -def _make_span(**overrides) -> Span: - defaults = { - "id": "span-123", - "name": "test-span", - "start_time": datetime(2026, 1, 1, tzinfo=timezone.utc), - "trace_id": "trace-123", - } - defaults.update(overrides) - return Span(**defaults) - - -def _make_module() -> tuple[AsyncMock, TracingModule]: - mock_service = AsyncMock(spec=TracingService) - module = TracingModule(tracing_service=mock_service) - return mock_service, module - - -def _make_activity_error() -> ActivityError: - return ActivityError( - "activity timed out", - scheduled_event_id=1, - started_event_id=2, - identity="worker-1", - activity_type="start-span", - activity_id="activity-1", - retry_state=None, - ) - - -def _make_metric_meter() -> MagicMock: - mock_meter = MagicMock() - mock_meter.create_counter.return_value = MagicMock() - return mock_meter - - -class TestStartSpan: - async def test_start_span_with_task_id(self): - mock_service, module = _make_module() - expected = _make_span(task_id="task-abc") - mock_service.start_span.return_value = expected - - with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): - result = await module.start_span( - trace_id="trace-123", - name="test-span", - task_id="task-abc", - ) - - assert result == expected - assert result.task_id == "task-abc" - mock_service.start_span.assert_called_once_with( - trace_id="trace-123", - name="test-span", - input=None, - parent_id=None, - data=None, - task_id="task-abc", - ) - - async def test_start_span_without_task_id(self): - mock_service, module = _make_module() - expected = _make_span() - mock_service.start_span.return_value = expected - - with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): - result = await module.start_span(trace_id="trace-123", name="test-span") - - assert result == expected - mock_service.start_span.assert_called_once_with( - trace_id="trace-123", - name="test-span", - input=None, - parent_id=None, - data=None, - task_id=None, - ) - - -class TestEndSpan: - async def test_end_span_preserves_task_id(self): - mock_service, module = _make_module() - span = _make_span(task_id="task-abc") - expected = _make_span( - task_id="task-abc", - end_time=datetime(2026, 1, 1, tzinfo=timezone.utc), - ) - mock_service.end_span.return_value = expected - - with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): - result = await module.end_span(trace_id="trace-123", span=span) - - assert result == expected - assert result.task_id == "task-abc" - mock_service.end_span.assert_called_once_with(trace_id="trace-123", span=span) - - -class TestTracingModuleTemporalPath: - async def test_start_span_in_workflow_returns_none_when_activity_fails(self): - mock_service, module = _make_module() - mock_meter = _make_metric_meter() - - with patch.object(_tracing_mod, "in_temporal_workflow", return_value=True), patch.object( - _tracing_mod, "ActivityHelpers" - ) as mock_helpers, patch.object(_tracing_mod.workflow, "logger") as mock_logger, patch.object( - _tracing_mod.workflow, "metric_meter", return_value=mock_meter - ): - mock_helpers.execute_activity = AsyncMock(side_effect=_make_activity_error()) - result = await module.start_span(trace_id="trace-123", name="test-span") - - assert result is None - mock_logger.warning.assert_called_once() - mock_meter.create_counter.assert_called_once_with( - _tracing_mod.TEMPORAL_SPAN_ACTIVITY_DROPPED_METRIC, - description="Temporal tracing span activities dropped after fail-open", - unit="1", - ) - mock_meter.create_counter.return_value.add.assert_called_once_with(1, {"event_type": "start"}) - mock_helpers.execute_activity.assert_called_once() - mock_service.start_span.assert_not_called() - - async def test_end_span_in_workflow_returns_span_when_activity_fails(self): - mock_service, module = _make_module() - span = _make_span() - mock_meter = _make_metric_meter() - - with patch.object(_tracing_mod, "in_temporal_workflow", return_value=True), patch.object( - _tracing_mod, "ActivityHelpers" - ) as mock_helpers, patch.object(_tracing_mod.workflow, "logger") as mock_logger, patch.object( - _tracing_mod.workflow, "metric_meter", return_value=mock_meter - ): - mock_helpers.execute_activity = AsyncMock(side_effect=_make_activity_error()) - result = await module.end_span(trace_id="trace-123", span=span) - - assert result == span - mock_logger.warning.assert_called_once() - mock_meter.create_counter.assert_called_once_with( - _tracing_mod.TEMPORAL_SPAN_ACTIVITY_DROPPED_METRIC, - description="Temporal tracing span activities dropped after fail-open", - unit="1", - ) - mock_meter.create_counter.return_value.add.assert_called_once_with(1, {"event_type": "end"}) - mock_helpers.execute_activity.assert_called_once() - mock_service.end_span.assert_not_called() - - async def test_context_manager_skips_end_when_temporal_start_fails(self): - mock_service, module = _make_module() - - with patch.object(_tracing_mod, "in_temporal_workflow", return_value=True), patch.object( - _tracing_mod, "ActivityHelpers" - ) as mock_helpers, patch.object(_tracing_mod.workflow, "logger"): - mock_helpers.execute_activity = AsyncMock(side_effect=_make_activity_error()) - async with module.span(trace_id="trace-123", name="test-span") as span: - assert span is None - - mock_helpers.execute_activity.assert_called_once() - mock_service.start_span.assert_not_called() - mock_service.end_span.assert_not_called() - - async def test_start_span_in_workflow_propagates_unexpected_errors(self): - mock_service, module = _make_module() - - with patch.object(_tracing_mod, "in_temporal_workflow", return_value=True), patch.object( - _tracing_mod, "ActivityHelpers" - ) as mock_helpers: - mock_helpers.execute_activity = AsyncMock(side_effect=RuntimeError("bad response shape")) - try: - await module.start_span(trace_id="trace-123", name="test-span") - except RuntimeError as exc: - assert str(exc) == "bad response shape" - else: - raise AssertionError("Expected unexpected errors to propagate") - - mock_helpers.execute_activity.assert_called_once() - mock_service.start_span.assert_not_called() - - async def test_start_span_in_workflow_propagates_cancellation(self): - mock_service, module = _make_module() - activity_error = _make_activity_error() - mock_meter = _make_metric_meter() - - with patch.object(_tracing_mod, "in_temporal_workflow", return_value=True), patch.object( - _tracing_mod, "ActivityHelpers" - ) as mock_helpers, patch.object(_tracing_mod, "is_cancelled_exception", return_value=True), patch.object( - _tracing_mod.workflow, "logger" - ) as mock_logger, patch.object(_tracing_mod.workflow, "metric_meter", return_value=mock_meter): - mock_helpers.execute_activity = AsyncMock(side_effect=activity_error) - - with pytest.raises(ActivityError): - await module.start_span(trace_id="trace-123", name="test-span") - - mock_logger.warning.assert_not_called() - mock_meter.create_counter.assert_not_called() - mock_helpers.execute_activity.assert_called_once() - mock_service.start_span.assert_not_called() - - async def test_end_span_in_workflow_propagates_cancellation(self): - mock_service, module = _make_module() - span = _make_span() - activity_error = _make_activity_error() - mock_meter = _make_metric_meter() - - with patch.object(_tracing_mod, "in_temporal_workflow", return_value=True), patch.object( - _tracing_mod, "ActivityHelpers" - ) as mock_helpers, patch.object(_tracing_mod, "is_cancelled_exception", return_value=True), patch.object( - _tracing_mod.workflow, "logger" - ) as mock_logger, patch.object(_tracing_mod.workflow, "metric_meter", return_value=mock_meter): - mock_helpers.execute_activity = AsyncMock(side_effect=activity_error) - - with pytest.raises(ActivityError): - await module.end_span(trace_id="trace-123", span=span) - - mock_logger.warning.assert_not_called() - mock_meter.create_counter.assert_not_called() - mock_helpers.execute_activity.assert_called_once() - mock_service.end_span.assert_not_called() - - -class TestSpanContextManager: - async def test_span_context_manager_forwards_task_id(self): - mock_service, module = _make_module() - started = _make_span(task_id="task-abc") - mock_service.start_span.return_value = started - mock_service.end_span.return_value = started - - with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): - async with module.span( - trace_id="trace-123", - name="test-span", - task_id="task-abc", - ) as span: - assert span is not None - assert span.task_id == "task-abc" - - assert mock_service.start_span.call_args.kwargs["task_id"] == "task-abc" - mock_service.end_span.assert_called_once() - - async def test_span_context_manager_noop_when_no_trace_id(self): - mock_service, module = _make_module() - - with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): - async with module.span(trace_id="", name="test-span") as span: - assert span is None - - mock_service.start_span.assert_not_called() - mock_service.end_span.assert_not_called() - - -class TestTurnSpan: - async def test_turn_span_records_aggregate_usage_in_data(self): - mock_service, module = _make_module() - started = _make_span(task_id="task-abc") - mock_service.start_span.return_value = started - mock_service.end_span.return_value = started - - with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): - async with module.turn_span( - trace_id="trace-123", - name="turn", - task_id="task-abc", - ) as turn: - assert isinstance(turn, TurnSpan) - turn.output = {"response": "hello"} - turn.record_usage( - usage={"input_tokens": 100, "output_tokens": 40, "total_tokens": 140}, - cost_usd=0.0125, - ) - - ended_span = mock_service.end_span.call_args.kwargs["span"] - assert ended_span.data["usage"] == { - "input_tokens": 100, - "output_tokens": 40, - "total_tokens": 140, - } - assert ended_span.data["cost_usd"] == 0.0125 - # The aggregate lives in data, never in output โ€” output stays payload-only - assert ended_span.output == {"response": "hello"} - - async def test_turn_span_record_usage_with_turn_usage(self): - mock_service, module = _make_module() - started = _make_span() - mock_service.start_span.return_value = started - mock_service.end_span.return_value = started - - turn_usage = TurnUsage( - model="gpt-4o", - input_tokens=10, - output_tokens=5, - cached_input_tokens=2, - total_tokens=15, - cost_usd=0.5, - ) - with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): - async with module.turn_span(trace_id="trace-123", name="turn") as turn: - turn.record_usage(turn_usage) - - ended_span = mock_service.end_span.call_args.kwargs["span"] - # cost_usd is lifted out of the blob to data["cost_usd"] - assert ended_span.data["cost_usd"] == 0.5 - assert ended_span.data["usage"] == { - "model": "gpt-4o", - "input_tokens": 10, - "output_tokens": 5, - "cached_input_tokens": 2, - "total_tokens": 15, - "num_tool_calls": 0, - "num_reasoning_blocks": 0, - } - - async def test_turn_span_explicit_cost_overrides_turn_usage_cost(self): - mock_service, module = _make_module() - started = _make_span() - mock_service.start_span.return_value = started - mock_service.end_span.return_value = started - - with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): - async with module.turn_span(trace_id="trace-123", name="turn") as turn: - turn.record_usage(TurnUsage(input_tokens=1, cost_usd=0.5), cost_usd=0.75) - - ended_span = mock_service.end_span.call_args.kwargs["span"] - assert ended_span.data["cost_usd"] == 0.75 - - async def test_turn_span_warns_on_unrecognized_usage_keys(self, caplog): - mock_service, module = _make_module() - started = _make_span() - mock_service.start_span.return_value = started - mock_service.end_span.return_value = started - - with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): - async with module.turn_span(trace_id="trace-123", name="turn") as turn: - with caplog.at_level("WARNING"): - turn.record_usage(usage={"inputTokens": 10}) - - assert any("no recognized token keys" in message for message in caplog.messages) - - async def test_turn_span_preserves_existing_data(self): - mock_service, module = _make_module() - started = _make_span(data={"custom": "value"}) - mock_service.start_span.return_value = started - mock_service.end_span.return_value = started - - with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): - async with module.turn_span(trace_id="trace-123", name="turn", data={"custom": "value"}) as turn: - turn.record_usage(usage={"prompt_tokens": 3, "completion_tokens": 4}) - - ended_span = mock_service.end_span.call_args.kwargs["span"] - assert ended_span.data["custom"] == "value" - assert ended_span.data["usage"] == {"prompt_tokens": 3, "completion_tokens": 4} - - async def test_turn_span_warns_and_replaces_non_dict_data(self, caplog): - mock_service, module = _make_module() - started = _make_span(data=[{"item": 1}]) - mock_service.start_span.return_value = started - mock_service.end_span.return_value = started - - with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): - async with module.turn_span(trace_id="trace-123", name="turn") as turn: - with caplog.at_level("WARNING"): - turn.record_usage(usage={"input_tokens": 1, "output_tokens": 2}) - - assert any("existing data will be replaced" in message for message in caplog.messages) - ended_span = mock_service.end_span.call_args.kwargs["span"] - assert ended_span.data == {"usage": {"input_tokens": 1, "output_tokens": 2}} - - async def test_turn_span_dict_data_does_not_warn(self, caplog): - mock_service, module = _make_module() - started = _make_span(data={"custom": "value"}) - mock_service.start_span.return_value = started - mock_service.end_span.return_value = started - - with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): - async with module.turn_span(trace_id="trace-123", name="turn") as turn: - with caplog.at_level("WARNING"): - turn.record_usage(usage={"input_tokens": 1}) - - assert not any("existing data will be replaced" in message for message in caplog.messages) - - async def test_turn_span_cost_only(self): - mock_service, module = _make_module() - started = _make_span() - mock_service.start_span.return_value = started - mock_service.end_span.return_value = started - - with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): - async with module.turn_span(trace_id="trace-123", name="turn") as turn: - turn.record_usage(cost_usd=0.5) - - ended_span = mock_service.end_span.call_args.kwargs["span"] - assert ended_span.data == {"cost_usd": 0.5} - assert "usage" not in ended_span.data - - async def test_turn_span_noop_when_no_trace_id(self): - mock_service, module = _make_module() - - with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False): - async with module.turn_span(trace_id="", name="turn") as turn: - assert turn.span is None - # Must not raise when tracing is disabled - turn.record_usage(usage={"input_tokens": 1}, cost_usd=0.1) - turn.output = {"response": "x"} - assert turn.output is None - - mock_service.start_span.assert_not_called() - mock_service.end_span.assert_not_called() diff --git a/tests/lib/adk/test_tracing_service.py b/tests/lib/adk/test_tracing_service.py 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 c7ed0b3f6..000000000 --- a/tests/lib/cli/test_environment_config.py +++ /dev/null @@ -1,346 +0,0 @@ -"""Tests for AgentEnvironmentsConfig.""" - -import tempfile - -import pytest - -from agentex.lib.sdk.config.environment_config import ( - AgentAuthConfig, - AgentKubernetesConfig, - AgentEnvironmentConfig, - AgentEnvironmentsConfig, - load_environments_config, -) - - -class TestAgentEnvironmentsConfig: - """Test cases for AgentEnvironmentsConfig.get_config_for_env method.""" - - @pytest.fixture - def single_env_config(self) -> AgentEnvironmentsConfig: - """Config with a single environment using direct key name.""" - return AgentEnvironmentsConfig( - schema_version="v1", - environments={ - "dev": AgentEnvironmentConfig( - kubernetes=AgentKubernetesConfig(namespace="dev-ns"), - auth=AgentAuthConfig(principal={"user_id": "dev-user"}), - ) - }, - ) - - @pytest.fixture - def multi_env_config(self) -> AgentEnvironmentsConfig: - """Config with multiple environments using direct key names.""" - return AgentEnvironmentsConfig( - schema_version="v1", - environments={ - "dev": AgentEnvironmentConfig( - kubernetes=AgentKubernetesConfig(namespace="dev-ns"), - auth=AgentAuthConfig(principal={"user_id": "dev-user"}), - ), - "staging": AgentEnvironmentConfig( - kubernetes=AgentKubernetesConfig(namespace="staging-ns"), - auth=AgentAuthConfig(principal={"user_id": "staging-user"}), - ), - "prod": AgentEnvironmentConfig( - kubernetes=AgentKubernetesConfig(namespace="prod-ns"), - auth=AgentAuthConfig(principal={"user_id": "prod-user"}), - ), - }, - ) - - @pytest.fixture - def multi_cluster_same_env_config(self) -> AgentEnvironmentsConfig: - """Config with multiple clusters mapping to the same environment keyword.""" - return AgentEnvironmentsConfig( - schema_version="v1", - environments={ - "dev-aws": AgentEnvironmentConfig( - kubernetes=AgentKubernetesConfig(namespace="dev-ns-aws"), - environment="dev", - auth=AgentAuthConfig(principal={"user_id": "dev-aws-user"}), - ), - "dev-gcp": AgentEnvironmentConfig( - kubernetes=AgentKubernetesConfig(namespace="dev-ns-gcp"), - environment="dev", - auth=AgentAuthConfig(principal={"user_id": "dev-gcp-user"}), - ), - "prod": AgentEnvironmentConfig( - kubernetes=AgentKubernetesConfig(namespace="prod-ns"), - auth=AgentAuthConfig(principal={"user_id": "prod-user"}), - ), - }, - ) - - def test_get_config_by_exact_key_match(self, single_env_config: AgentEnvironmentsConfig): - """Test that exact key match returns the correct config.""" - result = single_env_config.get_config_for_env("dev") - assert result is not None - - def test_get_config_nonexistent_env_raises_error(self, single_env_config: AgentEnvironmentsConfig): - """Test that requesting non-existent environment raises ValueError.""" - with pytest.raises(ValueError, match="not found"): - single_env_config.get_config_for_env("nonexistent") - - def test_get_config_exact_key_with_multiple_envs(self, multi_env_config: AgentEnvironmentsConfig): - """Test getting config by exact key when multiple environments exist.""" - result = multi_env_config.get_config_for_env("staging") - assert result is not None - - def test_get_config_by_specific_cluster_name(self, multi_cluster_same_env_config: AgentEnvironmentsConfig): - """Test getting config by specific cluster name (e.g., dev-aws).""" - result = multi_cluster_same_env_config.get_config_for_env("dev-aws") - assert result is not None - - def test_get_configs_without_explicit_mapping(self, single_env_config: AgentEnvironmentsConfig): - """Test getting config without explicit mapping returns a dict with env name as key.""" - result = single_env_config.get_configs_for_env("dev") - assert isinstance(result, dict) - assert len(result) == 1 - assert "dev" in result - assert result["dev"] == single_env_config.get_config_for_env("dev") - - def test_multiple_envs_same_keyword_returns_multiple(self, multi_cluster_same_env_config: AgentEnvironmentsConfig): - """Test that querying 'dev' when multiple envs have environment='dev' returns multiple. - - Returns a dict mapping env names (dev-aws, dev-gcp) to their configs. - """ - result = multi_cluster_same_env_config.get_configs_for_env("dev") - assert isinstance(result, dict) - assert len(result) == 2 - assert "dev-aws" in result - assert "dev-gcp" in result - assert result["dev-aws"].kubernetes.namespace == "dev-ns-aws" - assert result["dev-gcp"].kubernetes.namespace == "dev-ns-gcp" - - def test_list_environments(self, multi_env_config: AgentEnvironmentsConfig): - """Test listing all environment names.""" - envs = multi_env_config.list_environments() - assert set(envs) == {"dev", "staging", "prod"} - - -class TestLoadEnvironmentsConfig: - """Test cases for the load_environments_config yaml loader.""" - - def test_load_single_env_yaml(self): - """Test loading a YAML file with a single environment.""" - yaml_content = """ -schema_version: v1 -environments: - dev: - kubernetes: - namespace: dev-namespace - auth: - principal: - user_id: "user-123" - account_id: "account-456" -""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - f.write(yaml_content) - f.flush() - - config = load_environments_config(f.name) - - assert config.schema_version == "v1" - assert "dev" in config.environments - assert config.environments["dev"].kubernetes.namespace == "dev-namespace" - assert config.environments["dev"].auth.principal["user_id"] == "user-123" - - def test_load_multi_env_yaml(self): - """Test loading a YAML file with multiple environments.""" - yaml_content = """ -schema_version: v1 -environments: - dev: - kubernetes: - namespace: dev-namespace - auth: - principal: - user_id: "dev-user" - account_id: "dev-account" - prod: - kubernetes: - namespace: prod-namespace - auth: - principal: - user_id: "prod-user" - account_id: "prod-account" -""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - f.write(yaml_content) - f.flush() - - config = load_environments_config(f.name) - - assert "dev" in config.environments - assert "prod" in config.environments - assert config.environments["dev"].kubernetes.namespace == "dev-namespace" - assert config.environments["prod"].kubernetes.namespace == "prod-namespace" - - def test_load_yaml_with_environment_field_mapping(self): - """Test loading YAML where environments use the 'environment' field for mapping.""" - yaml_content = """ -schema_version: v1 -environments: - dev-aws: - environment: dev - kubernetes: - namespace: dev-aws-ns - auth: - principal: - user_id: "aws-user" - dev-gcp: - environment: dev - kubernetes: - namespace: dev-gcp-ns - auth: - principal: - user_id: "gcp-user" -""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - f.write(yaml_content) - f.flush() - - config = load_environments_config(f.name) - - assert config.environments["dev-aws"].environment == "dev" - assert config.environments["dev-gcp"].environment == "dev" - assert config.environments["dev-aws"].kubernetes.namespace == "dev-aws-ns" - assert config.environments["dev-gcp"].kubernetes.namespace == "dev-gcp-ns" - - def test_load_yaml_with_helm_overrides(self): - """Test loading YAML with helm_overrides.""" - yaml_content = """ -schema_version: v1 -environments: - dev: - kubernetes: - namespace: dev-namespace - auth: - principal: - user_id: "user-123" - helm_overrides: - replicaCount: 3 - resources: - requests: - cpu: "500m" - memory: "1Gi" -""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - f.write(yaml_content) - f.flush() - - config = load_environments_config(f.name) - - assert config.environments["dev"].helm_overrides["replicaCount"] == 3 - assert config.environments["dev"].helm_overrides["resources"]["requests"]["cpu"] == "500m" - - def test_load_yaml_with_custom_helm_repo(self): - """Test loading YAML with custom helm repository settings.""" - yaml_content = """ -schema_version: v1 -environments: - dev: - kubernetes: - namespace: dev-namespace - auth: - principal: - user_id: "user-123" - helm_repository_name: custom-repo - helm_repository_url: https://custom.example.com/charts -""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - f.write(yaml_content) - f.flush() - - config = load_environments_config(f.name) - - assert config.environments["dev"].helm_repository_name == "custom-repo" - assert config.environments["dev"].helm_repository_url == "https://custom.example.com/charts" - - def test_load_nonexistent_yaml_raises_file_not_found(self): - """Test that loading non-existent file raises FileNotFoundError.""" - with pytest.raises(FileNotFoundError, match="environments.yaml not found"): - load_environments_config("/nonexistent/path/environments.yaml") - - def test_load_empty_yaml_raises_value_error(self): - """Test that loading empty YAML file raises ValueError.""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - f.write("") - f.flush() - - with pytest.raises(ValueError, match="empty"): - load_environments_config(f.name) - - def test_load_invalid_yaml_syntax_raises_value_error(self): - """Test that loading invalid YAML syntax raises ValueError.""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - f.write("invalid: yaml: content: [") - f.flush() - - with pytest.raises(ValueError, match="Invalid YAML"): - load_environments_config(f.name) - - def test_load_yaml_missing_required_auth_raises_error(self): - """Test that YAML missing required 'auth' field raises validation error.""" - yaml_content = """ -schema_version: v1 -environments: - dev: - kubernetes: - namespace: dev-namespace -""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - f.write(yaml_content) - f.flush() - - with pytest.raises(ValueError, match="Failed to load"): - load_environments_config(f.name) - - def test_load_yaml_with_oci_registry(self): - """Test loading YAML with nested oci_registry configuration.""" - yaml_content = """ -schema_version: v1 -environments: - dev: - kubernetes: - namespace: dev-namespace - auth: - principal: - user_id: "user-123" - oci_registry: - url: us-west1-docker.pkg.dev/my-project/my-repo - provider: gar - chart_version: "0.2.0" -""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - f.write(yaml_content) - f.flush() - - config = load_environments_config(f.name) - - env = config.environments["dev"] - assert env.oci_registry is not None - assert env.oci_registry.url == "us-west1-docker.pkg.dev/my-project/my-repo" - assert env.oci_registry.provider == "gar" - assert env.oci_registry.chart_version == "0.2.0" - - def test_load_yaml_without_oci_registry(self): - """Test that oci_registry is None when not specified in YAML.""" - yaml_content = """ -schema_version: v1 -environments: - dev: - kubernetes: - namespace: dev-namespace - auth: - principal: - user_id: "user-123" -""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - f.write(yaml_content) - f.flush() - - config = load_environments_config(f.name) - assert config.environments["dev"].oci_registry is None diff --git a/tests/lib/cli/test_init_templates.py b/tests/lib/cli/test_init_templates.py 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/harness/__init__.py b/tests/lib/core/harness/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/lib/core/harness/_fakes.py b/tests/lib/core/harness/_fakes.py deleted file mode 100644 index f9fd34a45..000000000 --- a/tests/lib/core/harness/_fakes.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Shared test doubles for the unified harness test suites. - -A single superset implementation of the in-memory tracing backend used across -the harness tests. Three recording shapes were previously duplicated: - -- Shape-1 (richest): ``started`` = ``(name, parent_id, input)`` 3-tuples, - ``ended`` = ``(name, output)`` 2-tuples, plus an ``ended_spans`` list of the - closed ``FakeSpan`` objects (which carry ``.name``, ``.output``, ``.data``). -- Shape-2: ``started`` = ``(name, parent_id)`` 2-tuples, ``ended`` = - ``(name, output)``. -- Shape-3: ``started`` = bare names, ``ended`` = bare outputs. - -``FakeTracing`` records the richest (shape-1) form and exposes read-only -convenience properties (``started_names``, ``started_pairs``, -``ended_outputs``) so shape-2 and shape-3 assertions stay clean. -""" - -from __future__ import annotations - -from typing import Any - - -class FakeSpan: - def __init__(self, name: str) -> None: - self.name = name - self.output: Any = None - self.data: Any = None - - -class FakeTracing: - def __init__(self) -> None: - self.started: list[tuple[str, Any, Any]] = [] - self.ended: list[tuple[str, Any]] = [] - self.ended_spans: list[FakeSpan] = [] - - async def start_span( - self, - *, - trace_id: str, - name: str, - input: Any = None, - parent_id: Any = None, - data: Any = None, - task_id: Any = None, - ) -> FakeSpan: - self.started.append((name, parent_id, input)) - return FakeSpan(name) - - async def end_span(self, *, trace_id: str, span: FakeSpan) -> None: - self.ended.append((span.name, span.output)) - self.ended_spans.append(span) - - @property - def started_names(self) -> list[str]: - return [name for (name, _parent, _input) in self.started] - - @property - def started_pairs(self) -> list[tuple[str, Any]]: - return [(name, parent) for (name, parent, _input) in self.started] - - @property - def ended_outputs(self) -> list[Any]: - return [output for (_name, output) in self.ended] diff --git a/tests/lib/core/harness/conformance/__init__.py b/tests/lib/core/harness/conformance/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/lib/core/harness/conformance/conftest.py b/tests/lib/core/harness/conformance/conftest.py deleted file mode 100644 index e4da7f1e2..000000000 --- a/tests/lib/core/harness/conformance/conftest.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Conformance-suite test setup. - -Eagerly import every per-harness conformance module so each one's module-level -``register(...)`` calls run before any test executes. This makes -``all_fixtures()`` complete and independent of pytest's collection/import order -(the runner documents that cross-module registration order is not guaranteed), -so the cross-harness ``test_span_derivation_is_deterministic`` guard in -``test_conformance.py`` covers the full fixture set even when this directory is -run in isolation. -""" - -from __future__ import annotations - -# Importing these for their registration side effects only. -from . import ( - test_codex_conformance, # noqa: F401 - test_openai_conformance, # noqa: F401 - test_langgraph_conformance, # noqa: F401 - test_claude_code_conformance, # noqa: F401 - test_pydantic_ai_conformance, # noqa: F401 -) diff --git a/tests/lib/core/harness/conformance/runner.py b/tests/lib/core/harness/conformance/runner.py deleted file mode 100644 index 02a07f726..000000000 --- a/tests/lib/core/harness/conformance/runner.py +++ /dev/null @@ -1,507 +0,0 @@ -"""Shared conformance engine: every harness tap registers fixtures here. - -A fixture is (name, list[StreamTaskMessage]). The runner asserts two things: - -1. **Cross-channel logical equivalence**: yield_events and auto_send produce the - same *logical* sequence of delivered message contents. "Logical" means we - normalise away the streaming-envelope difference: - - yield channel delivers StreamTaskMessageFull(ToolResponseContent) verbatim. - - auto_send channel delivers the same tool-response by opening a streaming - context with the full content and closing it immediately (Start+Done on the - wire), not a Full event. - Both reduce to the same LogicalDelivery(type, identity, payload) tuple; the - conformance test compares those normalised sequences. - - `payload` carries the content that callers actually consume: - - text: initial_content.content prepended, then accumulated delta string - - reasoning: initial_content.summary joined, then accumulated delta string - - tool_request: the arguments dict (JSON-sorted), from Start content - - tool_response: the content value (str) - This catches a channel that delivers the right structural shape but corrupts, - drops, or omits initial_content (including reasoning summary) or payload. - -2. **Span signal equivalence**: each channel is driven with its own recording - tracer that captures every SpanSignal it actually receives in handle(); the - two channels' recorded signal lists must be identical. Comparing what each - channel genuinely emitted (rather than re-deriving from the events) catches a - regression where a channel skips deriver.observe() for some event type. - -Registry shared-state hazard: `_REGISTRY` is process-global. Every `test_*.py` -module that calls `register()` at import time contributes to it, so a module -that parametrizes over `all_fixtures()` will see fixtures registered by ANY -other conformance module imported earlier in the same pytest process (collection -order is not guaranteed). To stay deterministic, each future harness conformance -module should register and parametrize over its OWN fixtures (e.g. keep a -module-local list it both registers and parametrizes), rather than relying on -cross-module global accumulation via `all_fixtures()`. - -Design decision โ€” Full-message handling in auto_send ----------------------------------------------------- -auto_send posts a StreamTaskMessageFull (tool_request or tool_response) by -opening a streaming context with the full content and closing it immediately, -rather than calling adk.messages.create. This open+close approach is retained -because: - - StreamingTaskMessageContext.close() persists initial_content when no deltas - have been streamed, so the message IS correctly persisted. - - It mirrors the pattern already used by the real langgraph streaming helper - (now in _langgraph_turn.py), keeping behavioural parity. - - Switching to adk.messages.create would require an additional injectable - dependency, adding surface area for no observable benefit. -The conformance test treats this as an ACCEPTABLE envelope difference: at the -logical-content level, Full(ToolResponseContent) from yield and -Start(content)+Done from auto_send are equivalent. The recorded span signals are -identical because both adapters drive the same SpanDeriver.observe() call -sequence and forward every signal to their tracer. - -auto_send DELIVERS streamed tool-request messages (Start+Done): both channels -produce a LogicalDelivery for a streamed tool_request, and the cross-channel -assertion verifies it is delivered on both. -""" - -from __future__ import annotations - -import json -from typing import Any, NamedTuple, override -from dataclasses import dataclass - -from agentex.types.text_delta import TextDelta -from agentex.types.task_message import TaskMessage -from agentex.lib.core.harness.types import SpanSignal, StreamTaskMessage -from agentex.lib.core.harness.tracer import SpanTracer -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.types.reasoning_content_delta import ReasoningContentDelta -from agentex.lib.core.harness.span_derivation import SpanDeriver - -from .._fakes import FakeTracing - - -@dataclass -class Fixture: - name: str - events: list[StreamTaskMessage] - - -_REGISTRY: list[Fixture] = [] - - -def register(fixture: Fixture) -> None: - _REGISTRY.append(fixture) - - -def all_fixtures() -> list[Fixture]: - return list(_REGISTRY) - - -def run_pure_async(coro: Any) -> Any: - """Drive a *pure* (I/O-free) coroutine to completion without an event loop. - - Conformance fixtures are built at import time so they can parametrize the - tests below. The fixture-building coroutines only iterate in-memory events - and never suspend on a real future, so we step them by hand instead of - ``asyncio.run()``. ``asyncio.run()`` at import raises ``RuntimeError`` when a - loop is already running (programmatic pytest, a Jupyter kernel, or a - session-scoped asyncio loop); this driver is unaffected by ambient loop - state. It raises if the coroutine ever suspends on real I/O. - """ - try: - coro.send(None) - except StopIteration as stop: - return stop.value - coro.close() - raise RuntimeError("conformance fixture build unexpectedly suspended on real I/O") - - -def derive_all(events: list[StreamTaskMessage]) -> list[SpanSignal]: - d = SpanDeriver() - out: list[SpanSignal] = [] - for e in events: - out.extend(d.observe(e)) - out.extend(d.flush()) - return out - - -# --------------------------------------------------------------------------- -# Logical delivery normalisation -# --------------------------------------------------------------------------- - - -class LogicalDelivery(NamedTuple): - """A single logically-delivered message, channel-agnostic. - - `content_type` is the .type of the content (e.g. "text", "reasoning", - "tool_request", "tool_response"). `identity` is a frozenset of key=value - pairs that uniquely identify the content (e.g. tool_call_id for tool - messages, or index for text/reasoning). `payload` is a stable string - representation of the content callers actually consume: - - text: initial_content.content prepended to accumulated delta strings - - reasoning: initial_content.summary joined, prepended to accumulated - reasoning-content delta strings - - tool_request: JSON-sorted arguments from Start content - - tool_response: str(content) from Full event - """ - - content_type: str - identity: frozenset[tuple[str, Any]] - payload: str = "" - - -def _yield_logical_deliveries(events: list[StreamTaskMessage]) -> list[LogicalDelivery]: - """Extract logical deliveries from the yield channel's event list. - - The yield channel forwards events verbatim. A logical delivery is: - - A Full event (tool_request / tool_response): content delivered as-is. - - A Start + ... + Done sequence for text/reasoning/tool_request content. - - The `payload` field captures the content callers consume: - - text: initial_content.content (from Start) prepended to accumulated deltas - - reasoning: initial_content.summary joined (from Start) prepended to - accumulated reasoning-content deltas (this catches a channel that drops - the summary) - - tool_request: JSON-sorted arguments from the Start content (delivered on - both channels) - - tool_response: str(content) from Full event - """ - from agentex.types.text_content import TextContent - from agentex.types.reasoning_content import ReasoningContent - from agentex.types.tool_request_content import ToolRequestContent - - deliveries: list[LogicalDelivery] = [] - # Track which indices had a Start so we can pair with Done - started: dict[int, Any] = {} # index -> initial content - # Accumulate delta text per index (seed with initial_content text if present) - accumulated: dict[int, list[str]] = {} # index -> list of delta strings - - for event in events: - if isinstance(event, StreamTaskMessageStart): - if event.index is not None: - started[event.index] = event.content - # Seed accumulator with initial_content so a channel that drops - # initial_content but delivers deltas correctly will fail. - seed: list[str] = [] - if isinstance(event.content, TextContent) and event.content.content: - seed = [event.content.content] - elif isinstance(event.content, ReasoningContent) and event.content.summary: - seed = list(event.content.summary) - accumulated[event.index] = seed - elif isinstance(event, StreamTaskMessageDelta): - if event.index is not None and event.delta is not None: - if isinstance(event.delta, TextDelta) and event.delta.text_delta: - accumulated.setdefault(event.index, []).append(event.delta.text_delta) - elif isinstance(event.delta, ReasoningContentDelta) and event.delta.content_delta: - accumulated.setdefault(event.index, []).append(event.delta.content_delta) - elif isinstance(event, StreamTaskMessageDone): - if event.index is not None and event.index in started: - content = started.pop(event.index) - deltas = accumulated.pop(event.index, []) - ctype = getattr(content, "type", None) or "" - if ctype in ("text", "reasoning"): - deliveries.append( - LogicalDelivery( - content_type=ctype, - identity=frozenset({("index", event.index)}), - payload="".join(deltas), - ) - ) - elif ctype == "tool_request" and isinstance(content, ToolRequestContent): - # auto_send delivers streamed tool-request messages. Emit a - # delivery here so the cross-channel assertion verifies it is - # present on both channels. - deliveries.append( - LogicalDelivery( - content_type=ctype, - identity=frozenset( - { - ("tool_call_id", content.tool_call_id), - ("name", content.name), - } - ), - payload=json.dumps(content.arguments, sort_keys=True), - ) - ) - elif isinstance(event, StreamTaskMessageFull): - content = event.content - ctype = getattr(content, "type", None) or "" - if ctype == "tool_response": - from agentex.types.tool_response_content import ToolResponseContent - - if isinstance(content, ToolResponseContent): - deliveries.append( - LogicalDelivery( - content_type=ctype, - identity=frozenset( - { - ("tool_call_id", content.tool_call_id), - ("name", content.name), - } - ), - payload=str(content.content), - ) - ) - elif ctype == "tool_request": - from agentex.types.tool_request_content import ToolRequestContent - - if isinstance(content, ToolRequestContent): - deliveries.append( - LogicalDelivery( - content_type=ctype, - identity=frozenset( - { - ("tool_call_id", content.tool_call_id), - ("name", content.name), - } - ), - payload=json.dumps(content.arguments, sort_keys=True), - ) - ) - - return deliveries - - -# --------------------------------------------------------------------------- -# Fake streaming backend for auto_send conformance runner -# --------------------------------------------------------------------------- - - -class _FakeCtx: - """Mirrors StreamingTaskMessageContext: __aenter__ opens, close() closes.""" - - def __init__(self, sink: list[Any], content_type: str, initial_content: Any) -> None: - self.sink = sink - self.content_type = content_type - self.task_message = TaskMessage( - id="msg-conformance", - task_id="conformance-task", - content=initial_content, - ) - - async def __aenter__(self) -> "_FakeCtx": - self.sink.append(("open", self.content_type, self.task_message.content)) - return self - - async def __aexit__(self, *args: Any) -> bool: - await self.close() - return False - - async def close(self) -> None: - self.sink.append(("close", self.content_type)) - - async def stream_update(self, update: Any) -> Any: - self.sink.append(("update", update)) - return update - - -class _FakeStreaming: - """Fake streaming backend; records every context lifecycle event.""" - - def __init__(self) -> None: - self.sink: list[Any] = [] - - def streaming_task_message_context( - self, - task_id: str, - initial_content: Any, - streaming_mode: str = "coalesced", - created_at: Any = None, - ) -> _FakeCtx: - ctype = getattr(initial_content, "type", None) or "" - self.sink.append(("ctx", ctype, initial_content)) - return _FakeCtx(self.sink, ctype, initial_content) - - -class _RecordingTracer(SpanTracer): - """SpanTracer that records every SpanSignal it actually receives. - - Each delivery channel calls `tracer.handle(signal)` for every signal it - derives from the stream, so `received_signals` captures what the channel - genuinely emitted โ€” not a re-derivation. Comparing the two channels' - recorded lists catches regressions where a channel skips - `deriver.observe(event)` for some event type. - """ - - def __init__(self, tracing: Any) -> None: - super().__init__( - trace_id="conformance-trace", - parent_span_id="conformance-parent", - tracing=tracing, - ) - self.received_signals: list[SpanSignal] = [] - - @override - async def handle(self, signal: SpanSignal) -> None: - self.received_signals.append(signal) - await super().handle(signal) - - -async def _gen(events: list[StreamTaskMessage]): # type: ignore[return] - for e in events: - yield e - - -def _auto_send_logical_deliveries(sink: list[Any]) -> list[LogicalDelivery]: - """Extract logical deliveries from the auto_send fake streaming sink. - - Each context lifecycle in the sink looks like: - ("ctx", ctype, content) -- context created - ("open", ctype, content) -- context __aenter__ - [("update", delta), ...] -- optional deltas (StreamTaskMessageDelta) - ("close", ctype) -- context closed - - A logical delivery corresponds to each open+close pair. For text/reasoning - we identify by sequential position and build the payload by prepending the - initial_content text (TextContent.content) or summary (ReasoningContent.summary) - to accumulated deltas. This matches _yield_logical_deliveries so a channel - that drops initial_content or reasoning summary fails the comparison. - For tool messages we use tool_call_id + name and capture arguments/content. - """ - from agentex.types.text_content import TextContent - from agentex.types.reasoning_content import ReasoningContent - from agentex.types.tool_request_content import ToolRequestContent - from agentex.types.tool_response_content import ToolResponseContent - - deliveries: list[LogicalDelivery] = [] - open_idx = 0 - while open_idx < len(sink): - entry = sink[open_idx] - if entry[0] == "ctx": - ctype: str = entry[1] - content: Any = entry[2] - found_open = False - delta_parts: list[str] = [] - # Seed delta_parts with initial_content so payload comparison - # catches a channel that drops initial_content but delivers deltas. - if isinstance(content, TextContent) and content.content: - delta_parts = [content.content] - elif isinstance(content, ReasoningContent) and content.summary: - delta_parts = list(content.summary) - for j in range(open_idx + 1, len(sink)): - if sink[j][0] == "open" and sink[j][1] == ctype and not found_open: - found_open = True - elif found_open and sink[j][0] == "update": - # Accumulate delta content from StreamTaskMessageDelta - update = sink[j][1] - if isinstance(update, StreamTaskMessageDelta) and update.delta is not None: - if isinstance(update.delta, TextDelta) and update.delta.text_delta: - delta_parts.append(update.delta.text_delta) - elif isinstance(update.delta, ReasoningContentDelta) and update.delta.content_delta: - delta_parts.append(update.delta.content_delta) - elif sink[j][0] == "close" and sink[j][1] == ctype and found_open: - # Matched open+close: emit logical delivery with payload - if ctype in ("text", "reasoning"): - count = sum(1 for k in range(open_idx) if sink[k][0] == "ctx" and sink[k][1] == ctype) - deliveries.append( - LogicalDelivery( - content_type=ctype, - identity=frozenset({("seq", count)}), - payload="".join(delta_parts), - ) - ) - elif ctype == "tool_response": - if isinstance(content, ToolResponseContent): - deliveries.append( - LogicalDelivery( - content_type=ctype, - identity=frozenset( - { - ("tool_call_id", content.tool_call_id), - ("name", content.name), - } - ), - payload=str(content.content), - ) - ) - elif ctype == "tool_request": - if isinstance(content, ToolRequestContent): - deliveries.append( - LogicalDelivery( - content_type=ctype, - identity=frozenset( - { - ("tool_call_id", content.tool_call_id), - ("name", content.name), - } - ), - payload=json.dumps(content.arguments, sort_keys=True), - ) - ) - open_idx = j + 1 - break - else: - open_idx += 1 - else: - open_idx += 1 - - return deliveries - - -def _yield_text_reasoning_seq(deliveries: list[LogicalDelivery]) -> list[LogicalDelivery]: - """Re-key text/reasoning deliveries from index-based to seq-based identity. - - The yield channel uses event.index as identity; auto_send uses a sequential - counter. To compare across channels, normalise both to sequential position - within each content type. - """ - result: list[LogicalDelivery] = [] - counts: dict[str, int] = {} - for d in deliveries: - if d.content_type in ("text", "reasoning"): - seq = counts.get(d.content_type, 0) - counts[d.content_type] = seq + 1 - result.append( - LogicalDelivery( - content_type=d.content_type, - identity=frozenset({("seq", seq)}), - payload=d.payload, - ) - ) - else: - result.append(d) - return result - - -async def run_cross_channel_conformance( - fixture: Fixture, -) -> tuple[list[LogicalDelivery], list[LogicalDelivery], list[SpanSignal], list[SpanSignal]]: - """Run both channels over a fixture; return (yield_deliveries, auto_deliveries, - yield_spans, auto_spans). - - The caller asserts yield_deliveries == auto_deliveries and - yield_spans == auto_spans. The span signals are the ones each channel's - tracer ACTUALLY recorded while delivering (not a re-derivation), so a - regression where a channel skips deriver.observe() for some event type is - caught. - """ - from agentex.lib.core.harness.auto_send import auto_send - from agentex.lib.core.harness.yield_delivery import yield_events - - # --- yield channel --- - tracer_yield = _RecordingTracer(tracing=FakeTracing()) - yield_out = [e async for e in yield_events(_gen(fixture.events), tracer=tracer_yield)] - - # Span signals the yield channel actually emitted to its tracer - yield_spans = tracer_yield.received_signals - - # Logical deliveries from yield output - yield_deliveries = _yield_text_reasoning_seq(_yield_logical_deliveries(yield_out)) - - # --- auto_send channel --- - tracer_auto = _RecordingTracer(tracing=FakeTracing()) - fake_streaming = _FakeStreaming() - await auto_send( - _gen(fixture.events), - task_id="conformance-task", - tracer=tracer_auto, - streaming=fake_streaming, - ) - - # Span signals the auto_send channel actually emitted to its tracer - auto_spans = tracer_auto.received_signals - - # Logical deliveries from what the streaming backend received - auto_deliveries = _auto_send_logical_deliveries(fake_streaming.sink) - - return yield_deliveries, auto_deliveries, yield_spans, auto_spans diff --git a/tests/lib/core/harness/conformance/test_claude_code_conformance.py b/tests/lib/core/harness/conformance/test_claude_code_conformance.py deleted file mode 100644 index 010bc530b..000000000 --- a/tests/lib/core/harness/conformance/test_claude_code_conformance.py +++ /dev/null @@ -1,192 +0,0 @@ -"""Cross-channel conformance tests for the claude-code parser tap. - -Each fixture is a representative sequence of claude-code stream-json -envelopes, converted into canonical ``StreamTaskMessage*`` events via -``ClaudeCodeTurn``, then registered into the shared conformance runner. - -The conformance runner asserts two guarantees per fixture: - -1. **Logical-delivery equivalence**: ``yield_events`` and ``auto_send`` - produce the same logically-delivered message contents. - -2. **Span signal equivalence**: both channels emit the same ``SpanSignal`` - sequence to their ``SpanTracer``. - -Fixtures --------- -text-only: single ``assistant`` text block -tool-call-result: ``tool_use`` block followed by ``tool_result`` -thinking-block: ``thinking`` block with full text -multi-step: text + tool_use + tool_result + text (two model turns) - -Note ----- -Relative imports are used throughout (runner.py and these fixtures live in the -same package). The per-module ``_FIXTURES`` list is both registered globally -(via ``register()``) and parametrized locally so this module's tests are -self-contained regardless of global registry ordering (see runner.py docstring). -""" - -from __future__ import annotations - -import pytest - -from agentex.lib.adk._modules._claude_code_sync import convert_claude_code_to_agentex_events - -from .runner import ( - Fixture, - register, - run_pure_async, - run_cross_channel_conformance, -) - -# --------------------------------------------------------------------------- -# Convert claude-code envelopes to StreamTaskMessage* events -# --------------------------------------------------------------------------- - - -async def _envelopes_to_events(envelopes: list[dict]) -> list: - """Drive convert_claude_code_to_agentex_events and collect all events.""" - - async def _aiter(items): # type: ignore[return] - for item in items: - yield item - - return [e async for e in convert_claude_code_to_agentex_events(_aiter(envelopes))] - - -# --------------------------------------------------------------------------- -# Fixture definitions (raw claude-code envelope sequences) -# --------------------------------------------------------------------------- - -_TEXT_ENVELOPES = [ - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "The answer is 42."}]}, - } -] - -_TOOL_ENVELOPES = [ - { - "type": "assistant", - "message": { - "content": [ - { - "type": "tool_use", - "id": "call_read", - "name": "Read", - "input": {"path": "/workspace/README.md"}, - } - ] - }, - }, - { - "type": "user", - "message": { - "content": [ - { - "type": "tool_result", - "tool_use_id": "call_read", - "content": "# My Project\n\nA great project.", - } - ] - }, - }, -] - -_THINKING_ENVELOPES = [ - { - "type": "assistant", - "message": { - "content": [ - {"type": "thinking", "thinking": "Let me think about this carefully.\nStep 1: check the facts."}, - {"type": "text", "text": "Here is my answer."}, - ] - }, - } -] - -_MULTI_STEP_ENVELOPES = [ - # Turn 1: text + tool call - { - "type": "assistant", - "message": { - "content": [ - {"type": "text", "text": "Let me look that up."}, - { - "type": "tool_use", - "id": "call_bash", - "name": "Bash", - "input": {"command": "cat /etc/hostname"}, - }, - ] - }, - }, - { - "type": "user", - "message": { - "content": [ - { - "type": "tool_result", - "tool_use_id": "call_bash", - "content": "myhost", - } - ] - }, - }, - # Turn 2: final text after tool result - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "The hostname is myhost."}]}, - }, -] - - -# --------------------------------------------------------------------------- -# Build fixtures from envelopes at module load time -# --------------------------------------------------------------------------- - - -async def _build_fixture(name: str, envelopes: list[dict]) -> Fixture: - events = await _envelopes_to_events(envelopes) - return Fixture(name=name, events=events) - - -# Fixtures must exist before pytest collects (they parametrize the test below), -# so they are built at import time. The conversion only iterates in-memory -# envelopes โ€” it never suspends on a real future โ€” so we drive the coroutines to -# completion with the shared loop-free ``run_pure_async`` driver instead of -# asyncio.run(), which raises RuntimeError at import when an event loop is -# already running (programmatic pytest, a Jupyter kernel, or session-scoped -# asyncio loops). -_FIXTURES: list[Fixture] = [ - run_pure_async(_build_fixture("claude-code-text-only", _TEXT_ENVELOPES)), - run_pure_async(_build_fixture("claude-code-tool-call-result", _TOOL_ENVELOPES)), - run_pure_async(_build_fixture("claude-code-thinking-block", _THINKING_ENVELOPES)), - run_pure_async(_build_fixture("claude-code-multi-step", _MULTI_STEP_ENVELOPES)), -] - -# Register into the shared registry so all_fixtures() can enumerate them -for _f in _FIXTURES: - register(_f) - - -# --------------------------------------------------------------------------- -# Cross-channel conformance assertions -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize("fixture", _FIXTURES, ids=lambda f: f.name) -@pytest.mark.asyncio -async def test_cross_channel_equivalence(fixture: Fixture) -> None: - """yield_events and auto_send must produce equivalent logical deliveries - and identical span signals for every claude-code fixture. - """ - yield_deliveries, auto_deliveries, yield_spans, auto_spans = await run_cross_channel_conformance(fixture) - - assert yield_deliveries == auto_deliveries, ( - f"[{fixture.name}] logical deliveries differ:\n yield: {yield_deliveries}\n auto_send: {auto_deliveries}" - ) - assert yield_spans == auto_spans, ( - f"[{fixture.name}] span signals differ:\n yield: {yield_spans}\n auto_send: {auto_spans}" - ) diff --git a/tests/lib/core/harness/conformance/test_codex_conformance.py b/tests/lib/core/harness/conformance/test_codex_conformance.py deleted file mode 100644 index d51a73584..000000000 --- a/tests/lib/core/harness/conformance/test_codex_conformance.py +++ /dev/null @@ -1,215 +0,0 @@ -"""Conformance fixtures for the codex harness tap. - -Each fixture is derived from a ``CodexTurn`` and registered into the -cross-channel conformance runner so that span derivation is validated -alongside all other harness taps. - -Following the per-module registry pattern from runner.py: this module keeps -its own local list of fixtures, both registers them AND parametrizes over -them, to guarantee determinism regardless of pytest collection order. -""" - -from __future__ import annotations - -from typing import Any, AsyncIterator - -import pytest - -from agentex.lib.core.harness.types import StreamTaskMessage -from agentex.lib.adk._modules._codex_sync import convert_codex_to_agentex_events - -from .runner import Fixture, register, run_pure_async - - -async def _aiter(items: list[Any]) -> AsyncIterator[Any]: - for item in items: - yield item - - -async def _collect(events: list[Any]) -> list[StreamTaskMessage]: - return [msg async for msg in convert_codex_to_agentex_events(_aiter(events))] - - -def _build(events: list[Any]) -> list[StreamTaskMessage]: - # Loop-free driver: this runs at import time, where asyncio.run() would raise - # under an already-running loop (programmatic pytest, notebooks). - return run_pure_async(_collect(events)) - - -# --------------------------------------------------------------------------- -# Fixture 1: plain text response -# --------------------------------------------------------------------------- - -_CODEX_TEXT = Fixture( - name="codex-text", - events=_build( - [ - {"type": "thread.started", "thread_id": "thread-abc"}, - {"type": "turn.started"}, - { - "type": "item.started", - "item": {"id": "msg1", "type": "agent_message", "text": "Hello"}, - }, - { - "type": "item.updated", - "item": {"id": "msg1", "type": "agent_message", "text": "Hello, world"}, - }, - { - "type": "item.completed", - "item": {"id": "msg1", "type": "agent_message", "text": "Hello, world!"}, - }, - { - "type": "turn.completed", - "usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, - }, - ] - ), -) -register(_CODEX_TEXT) - -# --------------------------------------------------------------------------- -# Fixture 2: tool call (command_execution) -# --------------------------------------------------------------------------- - -_CODEX_TOOL = Fixture( - name="codex-tool-command", - events=_build( - [ - {"type": "thread.started", "thread_id": "thread-cmd"}, - { - "type": "item.started", - "item": { - "id": "tool1", - "type": "command_execution", - "command": "ls /workspace", - }, - }, - { - "type": "item.completed", - "item": { - "id": "tool1", - "type": "command_execution", - "command": "ls /workspace", - "aggregated_output": "file1.txt\nfile2.py", - "exit_code": 0, - }, - }, - { - "type": "turn.completed", - "usage": {"input_tokens": 20, "output_tokens": 8, "total_tokens": 28}, - }, - ] - ), -) -register(_CODEX_TOOL) - -# --------------------------------------------------------------------------- -# Fixture 3: reasoning block -# --------------------------------------------------------------------------- - -_CODEX_REASONING = Fixture( - name="codex-reasoning", - events=_build( - [ - {"type": "thread.started", "thread_id": "thread-reason"}, - { - "type": "item.started", - "item": {"id": "r1", "type": "reasoning", "text": ""}, - }, - { - "type": "item.updated", - "item": {"id": "r1", "type": "reasoning", "text": "Step 1: analyze the problem"}, - }, - { - "type": "item.completed", - "item": { - "id": "r1", - "type": "reasoning", - "text": "Step 1: analyze the problem\nStep 2: solve it", - }, - }, - { - "type": "item.started", - "item": {"id": "msg2", "type": "agent_message", "text": ""}, - }, - { - "type": "item.completed", - "item": {"id": "msg2", "type": "agent_message", "text": "The answer is 42."}, - }, - { - "type": "turn.completed", - "usage": { - "input_tokens": 30, - "output_tokens": 20, - "reasoning_tokens": 50, - "total_tokens": 100, - }, - }, - ] - ), -) -register(_CODEX_REASONING) - -# --------------------------------------------------------------------------- -# Fixture 4: multi-step (mcp_tool_call + follow-up text) -# --------------------------------------------------------------------------- - -_CODEX_MULTI = Fixture( - name="codex-multi-step", - events=_build( - [ - {"type": "thread.started", "thread_id": "thread-multi"}, - { - "type": "item.started", - "item": { - "id": "mcp1", - "type": "mcp_tool_call", - "server": "filesystem", - "tool": "read_file", - "arguments": {"path": "/workspace/README.md"}, - }, - }, - { - "type": "item.completed", - "item": { - "id": "mcp1", - "type": "mcp_tool_call", - "server": "filesystem", - "tool": "read_file", - "arguments": {"path": "/workspace/README.md"}, - "result": {"content": "# My Project"}, - }, - }, - { - "type": "item.started", - "item": {"id": "msg3", "type": "agent_message", "text": "The README says:"}, - }, - { - "type": "item.completed", - "item": { - "id": "msg3", - "type": "agent_message", - "text": "The README says: # My Project", - }, - }, - { - "type": "turn.completed", - "usage": {"input_tokens": 50, "output_tokens": 30, "total_tokens": 80}, - }, - ] - ), -) -register(_CODEX_MULTI) - - -# --------------------------------------------------------------------------- -# Local parametrized tests (cross-channel conformance) -# --------------------------------------------------------------------------- - -_LOCAL_FIXTURES = [_CODEX_TEXT, _CODEX_TOOL, _CODEX_REASONING, _CODEX_MULTI] - - -@pytest.mark.parametrize("fixture", _LOCAL_FIXTURES, ids=lambda f: f.name) -def test_codex_events_are_non_empty(fixture: Fixture) -> None: - """Every codex fixture yields at least one StreamTaskMessage*.""" - assert len(fixture.events) > 0 diff --git a/tests/lib/core/harness/conformance/test_conformance.py b/tests/lib/core/harness/conformance/test_conformance.py deleted file mode 100644 index 7c79f9397..000000000 --- a/tests/lib/core/harness/conformance/test_conformance.py +++ /dev/null @@ -1,299 +0,0 @@ -"""Cross-channel conformance tests: yield_events vs auto_send. - -What is asserted ----------------- -For each fixture the conformance runner drives BOTH delivery channels and -verifies two guarantees: - -1. **Logical-delivery equivalence**: the sequence of logically-delivered - messages is identical across channels. "Logical" normalises away the - streaming-envelope difference: - - yield channel delivers StreamTaskMessageFull(ToolResponseContent) as-is. - - auto_send delivers the same tool-response by opening a streaming context - with the full content and closing it immediately. - Both collapse to LogicalDelivery(content_type, identity, payload) tuples - that compare equal. The payload includes initial_content (TextContent.content - and ReasoningContent.summary) so a channel that drops initial content fails. - -2. **Span signal equivalence**: both channels feed the same pure SpanDeriver - over the same event sequence, so the derived span signals must be identical. - -What is NOT asserted --------------------- -Raw wire-level event shapes are NOT compared (that would fail by design: the -Full vs Start+Done envelope difference is a documented, acceptable choice in -auto_send โ€” see runner.py for the rationale). - -auto_send delivers streamed tool-request messages: both channels produce a -delivery for streamed tool_request, verified by the "streamed-tool-request" -fixture. -""" - -from __future__ import annotations - -import pytest - -from agentex.types.text_delta import TextDelta -from agentex.types.text_content import TextContent -from agentex.types.reasoning_content import ReasoningContent -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.types.reasoning_content_delta import ReasoningContentDelta - -from .runner import ( - Fixture, - register, - derive_all, - all_fixtures, - run_cross_channel_conformance, -) - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - -_FIXTURES: list[Fixture] = [ - # fixture 1: single tool call โ€” tool_request delivered via Full (classic path) - # plus a streamed tool_response via Full. Both channels should deliver both. - Fixture( - name="builtin-single-tool", - events=[ - StreamTaskMessageStart( - type="start", - index=0, - content=ToolRequestContent( - type="tool_request", author="agent", tool_call_id="c", name="Bash", arguments={} - ), - ), - StreamTaskMessageDone(type="done", index=0), - StreamTaskMessageFull( - type="full", - index=1, - content=ToolResponseContent( - type="tool_response", author="agent", tool_call_id="c", name="Bash", content="ok" - ), - ), - ], - ), - # fixture 2: streaming text โ€” exercises the text start/delta/done path. - # Uses non-empty initial_content so the payload comparison catches a channel - # that drops StreamTaskMessageStart.content (Greptile id 3438655533, P1). - Fixture( - name="streaming-text", - events=[ - StreamTaskMessageStart( - type="start", - index=0, - content=TextContent(type="text", author="agent", content="Init"), - ), - StreamTaskMessageDelta( - type="delta", - index=0, - delta=TextDelta(type="text", text_delta="Hello"), - ), - StreamTaskMessageDelta( - type="delta", - index=0, - delta=TextDelta(type="text", text_delta=" world"), - ), - StreamTaskMessageDone(type="done", index=0), - ], - ), - # fixture 3: reasoning block โ€” exercises reasoning span open/close + delivery. - # ReasoningContent.summary is included in the payload so a channel that drops - # the reasoning-summary fails (Greptile id 3438655533, P1). - Fixture( - name="reasoning-block", - events=[ - StreamTaskMessageStart( - type="start", - index=0, - content=ReasoningContent( - type="reasoning", - author="agent", - summary=["Thinking..."], - ), - ), - StreamTaskMessageDelta( - type="delta", - index=0, - delta=ReasoningContentDelta( - type="reasoning_content", - content_index=0, - content_delta="step 1", - ), - ), - StreamTaskMessageDone(type="done", index=0), - ], - ), - # fixture 4: streamed tool_request โ€” tool_request delivered via Start+Done - # (no Full). Both channels must produce a LogicalDelivery for this fixture. - Fixture( - name="streamed-tool-request", - events=[ - StreamTaskMessageStart( - type="start", - index=0, - content=ToolRequestContent( - type="tool_request", - author="agent", - tool_call_id="tr-1", - name="Read", - arguments={"path": "/tmp/foo"}, - ), - ), - StreamTaskMessageDone(type="done", index=0), - StreamTaskMessageFull( - type="full", - index=1, - content=ToolResponseContent( - type="tool_response", - author="agent", - tool_call_id="tr-1", - name="Read", - content="file contents", - ), - ), - ], - ), - # fixture 5: parallel tool calls + a tool that errors (AGX1-373 review, - # danielmillerp). The earlier fixtures only exercise one tool at a time, so - # equivalence is proven over trivially-orderable streams. This stresses the - # representative case: two tool spans open SIMULTANEOUSLY (p-ls opens via the - # streamed Start+Done path, p-read opens via Full while p-ls is still open), - # then close in a different order than they opened, and one of them returns - # an error. It guards against the two channels agreeing with each other while - # both mishandling interleaved/parallel spans or a failing tool. - # - # The failing tool sets ToolResponseContent.is_error=True (AGX1-371), which - # the span deriver threads onto the closed tool span's CloseSpan.is_error. - # Both channels feed the same deriver, so the recorded span signals โ€” error - # status included โ€” must match. - Fixture( - name="parallel-tools-with-error", - events=[ - # p-ls: streamed tool_request (opens its span at Done). - StreamTaskMessageStart( - type="start", - index=0, - content=ToolRequestContent( - type="tool_request", - author="agent", - tool_call_id="p-ls", - name="Bash", - arguments={"command": "ls /nope"}, - ), - ), - StreamTaskMessageDone(type="done", index=0), - # p-read: Full tool_request opens a second span while p-ls is open. - StreamTaskMessageFull( - type="full", - index=1, - content=ToolRequestContent( - type="tool_request", - author="agent", - tool_call_id="p-read", - name="Read", - arguments={"path": "/etc/hosts"}, - ), - ), - # p-ls errors and closes first (close order != open order). - StreamTaskMessageFull( - type="full", - index=2, - content=ToolResponseContent( - type="tool_response", - author="agent", - tool_call_id="p-ls", - name="Bash", - content="Error: ls: /nope: No such file or directory", - is_error=True, - ), - ), - # p-read succeeds and closes second. - StreamTaskMessageFull( - type="full", - index=3, - content=ToolResponseContent( - type="tool_response", - author="agent", - tool_call_id="p-read", - name="Read", - content="127.0.0.1 localhost", - ), - ), - ], - ), -] - -# Register all fixtures for backward-compatible use via all_fixtures() -for _f in _FIXTURES: - register(_f) - - -# --------------------------------------------------------------------------- -# Cross-channel conformance: logical equivalence + span equivalence -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize("fixture", _FIXTURES, ids=lambda f: f.name) -@pytest.mark.asyncio -async def test_cross_channel_equivalence(fixture: Fixture) -> None: - """Assert that yield_events and auto_send produce equivalent logical - deliveries and identical span signals for every fixture. - - This is the real cross-channel guarantee: the two delivery adapters - agree on WHAT was delivered (logical content) and HOW spans were derived, - even though their streaming-envelope shapes differ (Full vs Start+Done for - tool messages). - - The span signals are the ones each channel's tracer ACTUALLY recorded while - delivering, not a re-derivation, so a regression where one channel skips - deriver.observe() for some event type is caught here. - """ - yield_deliveries, auto_deliveries, yield_spans, auto_spans = await run_cross_channel_conformance(fixture) - - assert yield_deliveries == auto_deliveries, ( - f"[{fixture.name}] logical deliveries differ:\n yield: {yield_deliveries}\n auto_send: {auto_deliveries}" - ) - assert yield_spans == auto_spans, ( - f"[{fixture.name}] span signals differ:\n yield: {yield_spans}\n auto_send: {auto_spans}" - ) - - -# --------------------------------------------------------------------------- -# Backward-compatible determinism test (kept for regression coverage) -# --------------------------------------------------------------------------- - - -def test_span_derivation_is_deterministic() -> None: - """Span derivation over the same event list is idempotent, for EVERY - registered fixture across all harnesses. - - ``all_fixtures()`` is read at run time (not at collection/parametrize time) - so it sees fixtures registered by every conformance module, regardless of - import/collection order. The per-harness conformance modules are imported - eagerly via ``conftest.py`` in this directory, so this test covers the full - cross-harness fixture set even when run in isolation. (Parametrizing on - ``all_fixtures()`` at import time would freeze the set to whatever happened - to be registered before this module was collected.) - - Retained as a lightweight regression guard. The primary cross-channel - guarantee is asserted in test_cross_channel_equivalence above. - """ - fixtures = all_fixtures() - assert len(fixtures) > len(_FIXTURES), ( - "expected per-harness fixtures to be registered in addition to the " - f"{len(_FIXTURES)} generic ones; got {len(fixtures)} total โ€” a conformance " - "module's fixtures are not being registered (check conftest imports)" - ) - for fixture in fixtures: - assert derive_all(fixture.events) == derive_all(fixture.events), ( - f"[{fixture.name}] span derivation is not deterministic" - ) diff --git a/tests/lib/core/harness/conformance/test_langgraph_conformance.py b/tests/lib/core/harness/conformance/test_langgraph_conformance.py deleted file mode 100644 index a8d43aef6..000000000 --- a/tests/lib/core/harness/conformance/test_langgraph_conformance.py +++ /dev/null @@ -1,218 +0,0 @@ -"""Cross-channel conformance fixtures for LangGraph harness tap. - -Each fixture is built as a canonical sequence of ``StreamTaskMessage*`` events -that matches what ``convert_langgraph_to_agentex_events`` (via ``LangGraphTurn``) -emits for the given scenario. The fixtures are registered with the shared -conformance runner and exercised by both the cross-channel equivalence test -(yield_events vs auto_send) and the backward-compatible span-derivation test. - -LangGraph-specific note ------------------------ -LangGraph emits tool *requests* as ``StreamTaskMessageFull`` events (from the -"updates" stream), NOT as Start+Delta+Done like pydantic-ai. ``auto_send`` -handles Full events by opening a streaming context with the full content and -closing it immediately, so both channels deliver the same logical payload. -No ``coalesce_tool_requests`` option is needed. -""" - -from __future__ import annotations - -import pytest - -from agentex.types.text_content import TextContent -from agentex.types.reasoning_content import ReasoningContent -from agentex.types.task_message_delta import TextDelta -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.types.reasoning_content_delta import ReasoningContentDelta - -from .runner import Fixture, register, run_cross_channel_conformance - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - -_TEXT_ONLY = Fixture( - name="langgraph-text-only", - events=[ - StreamTaskMessageStart( - type="start", - index=0, - content=TextContent(type="text", author="agent", content=""), - ), - StreamTaskMessageDelta( - type="delta", - index=0, - delta=TextDelta(type="text", text_delta="Hello from LangGraph!"), - ), - StreamTaskMessageDone(type="done", index=0), - ], -) - -_SINGLE_TOOL = Fixture( - name="langgraph-single-tool", - events=[ - # LangGraph tool request is a Full event (from "updates" stream) - StreamTaskMessageFull( - type="full", - index=0, - content=ToolRequestContent( - type="tool_request", - author="agent", - tool_call_id="call_1", - name="get_weather", - arguments={"city": "Paris"}, - ), - ), - StreamTaskMessageFull( - type="full", - index=1, - content=ToolResponseContent( - type="tool_response", - author="agent", - tool_call_id="call_1", - name="get_weather", - content="Sunny, 72F", - ), - ), - StreamTaskMessageStart( - type="start", - index=2, - content=TextContent(type="text", author="agent", content=""), - ), - StreamTaskMessageDelta( - type="delta", - index=2, - delta=TextDelta(type="text", text_delta="The weather in Paris is sunny, 72F."), - ), - StreamTaskMessageDone(type="done", index=2), - ], -) - -_REASONING = Fixture( - name="langgraph-reasoning", - events=[ - StreamTaskMessageStart( - type="start", - index=0, - content=ReasoningContent( - type="reasoning", - author="agent", - summary=[], - content=[], - style="active", - ), - ), - StreamTaskMessageDelta( - type="delta", - index=0, - delta=ReasoningContentDelta( - type="reasoning_content", - content_index=0, - content_delta="Thinking about this...", - ), - ), - StreamTaskMessageDone(type="done", index=0), - StreamTaskMessageStart( - type="start", - index=1, - content=TextContent(type="text", author="agent", content=""), - ), - StreamTaskMessageDelta( - type="delta", - index=1, - delta=TextDelta(type="text", text_delta="The answer is 42."), - ), - StreamTaskMessageDone(type="done", index=1), - ], -) - -_MULTI_STEP = Fixture( - name="langgraph-multi-step", - events=[ - # Turn 1: streaming text - StreamTaskMessageStart( - type="start", - index=0, - content=TextContent(type="text", author="agent", content=""), - ), - StreamTaskMessageDelta( - type="delta", - index=0, - delta=TextDelta(type="text", text_delta="Let me search for that."), - ), - StreamTaskMessageDone(type="done", index=0), - # Tool request (Full โ€” from "updates" stream) - StreamTaskMessageFull( - type="full", - index=1, - content=ToolRequestContent( - type="tool_request", - author="agent", - tool_call_id="call_2", - name="search", - arguments={"query": "langgraph"}, - ), - ), - StreamTaskMessageFull( - type="full", - index=2, - content=ToolResponseContent( - type="tool_response", - author="agent", - tool_call_id="call_2", - name="search", - content="LangGraph is a framework for...", - ), - ), - # Turn 2: final streaming text - StreamTaskMessageStart( - type="start", - index=3, - content=TextContent(type="text", author="agent", content=""), - ), - StreamTaskMessageDelta( - type="delta", - index=3, - delta=TextDelta(type="text", text_delta="Based on my research, LangGraph is..."), - ), - StreamTaskMessageDone(type="done", index=3), - ], -) - -_LANGGRAPH_FIXTURES = [_TEXT_ONLY, _SINGLE_TOOL, _REASONING, _MULTI_STEP] - -for _fixture in _LANGGRAPH_FIXTURES: - register(_fixture) - - -# --------------------------------------------------------------------------- -# Cross-channel conformance: logical equivalence + span equivalence -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize("fixture", _LANGGRAPH_FIXTURES, ids=lambda f: f.name) -@pytest.mark.asyncio -async def test_cross_channel_equivalence(fixture: Fixture) -> None: - """Assert that yield_events and auto_send produce equivalent logical - deliveries and identical span signals for each LangGraph fixture. - - See runner.py for the full contract. The key LangGraph difference: tool - requests arrive as Full events rather than Start+Delta+Done, so auto_send - handles them by opening a streaming context with the full content and - closing it immediately โ€” both channels produce the same LogicalDelivery. - """ - yield_deliveries, auto_deliveries, yield_spans, auto_spans = await run_cross_channel_conformance(fixture) - - assert yield_deliveries == auto_deliveries, ( - f"[{fixture.name}] logical deliveries differ:\n yield: {yield_deliveries}\n auto_send: {auto_deliveries}" - ) - assert yield_spans == auto_spans, ( - f"[{fixture.name}] span signals differ:\n yield: {yield_spans}\n auto_send: {auto_spans}" - ) diff --git a/tests/lib/core/harness/conformance/test_openai_conformance.py b/tests/lib/core/harness/conformance/test_openai_conformance.py deleted file mode 100644 index e8630ca7f..000000000 --- a/tests/lib/core/harness/conformance/test_openai_conformance.py +++ /dev/null @@ -1,206 +0,0 @@ -"""OpenAI conformance fixtures for the shared harness span-derivation engine. - -The cross-channel guarantee is that yield-delivery and auto_send observe the -SAME canonical StreamTaskMessage* stream, so span derivation and logical -delivery over that stream must be equivalent regardless of channel. These -fixtures express the canonical sequences an OpenAI turn produces (text, -tool-call, reasoning, and a combined multi-step turn) and assert that property -via run_cross_channel_conformance. - -Registry hazard (see conformance/runner.py): _REGISTRY is process-global and -collection order across modules is not guaranteed. To stay deterministic this -module keeps its OWN fixture list and parametrizes over THAT list, rather than -over all_fixtures(). It still calls register() so the cross-module conformance -suite can see these fixtures too. -""" - -from __future__ import annotations - -import pytest - -from agentex.types.text_delta import TextDelta -from agentex.types.text_content import TextContent -from agentex.types.reasoning_content import ReasoningContent -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.types.reasoning_content_delta import ReasoningContentDelta - -from .runner import Fixture, register, run_cross_channel_conformance - -_OPENAI_FIXTURES: list[Fixture] = [] - - -def _add(fixture: Fixture) -> None: - """Register both module-locally (for parametrization) and globally.""" - _OPENAI_FIXTURES.append(fixture) - register(fixture) - - -# Text-only turn: start -> deltas -> done. -# Uses non-empty initial_content so payload comparison catches a channel that -# drops StreamTaskMessageStart.content. -_add( - Fixture( - name="openai-text-only", - events=[ - StreamTaskMessageStart( - type="start", - index=0, - content=TextContent(type="text", author="agent", content="Init"), - ), - StreamTaskMessageDelta(type="delta", index=0, delta=TextDelta(type="text", text_delta="Hel")), - StreamTaskMessageDelta(type="delta", index=0, delta=TextDelta(type="text", text_delta="lo")), - StreamTaskMessageDone(type="done", index=0), - ], - ) -) - -# Tool-call turn: Full(ToolRequestContent) for the call + Full(ToolResponseContent) -# for the result, matched by tool_call_id. Mirrors the OpenAI converter's tool path. -_add( - Fixture( - name="openai-tool-call", - events=[ - StreamTaskMessageFull( - type="full", - index=0, - content=ToolRequestContent( - type="tool_request", - author="agent", - tool_call_id="call_1", - name="get_weather", - arguments={"city": "SF"}, - ), - ), - StreamTaskMessageFull( - type="full", - index=1, - content=ToolResponseContent( - type="tool_response", - author="agent", - tool_call_id="call_1", - name="get_weather", - content="72F", - ), - ), - ], - ) -) - -# Reasoning turn: start(ReasoningContent) -> content deltas -> done. -# ReasoningContent.summary is seeded in the payload so a channel that drops the -# summary fails the cross-channel comparison. -_add( - Fixture( - name="openai-reasoning", - events=[ - StreamTaskMessageStart( - type="start", - index=0, - content=ReasoningContent( - type="reasoning", - author="agent", - summary=["Thinking..."], - ), - ), - StreamTaskMessageDelta( - type="delta", - index=0, - delta=ReasoningContentDelta( - type="reasoning_content", - content_index=0, - content_delta="step 1", - ), - ), - StreamTaskMessageDone(type="done", index=0), - ], - ) -) - -# Multi-step turn: reasoning, then a tool round, then the final answer text. -_add( - Fixture( - name="openai-multi-step", - events=[ - StreamTaskMessageStart( - type="start", - index=0, - content=ReasoningContent( - type="reasoning", - author="agent", - summary=["plan"], - ), - ), - StreamTaskMessageDelta( - type="delta", - index=0, - delta=ReasoningContentDelta( - type="reasoning_content", - content_index=0, - content_delta="elaboration", - ), - ), - StreamTaskMessageDone(type="done", index=0), - StreamTaskMessageFull( - type="full", - index=1, - content=ToolRequestContent( - type="tool_request", - author="agent", - tool_call_id="call_2", - name="search", - arguments={"q": "x"}, - ), - ), - StreamTaskMessageFull( - type="full", - index=2, - content=ToolResponseContent( - type="tool_response", - author="agent", - tool_call_id="call_2", - name="search", - content="result", - ), - ), - StreamTaskMessageStart( - type="start", - index=3, - content=TextContent(type="text", author="agent", content=""), - ), - StreamTaskMessageDelta(type="delta", index=3, delta=TextDelta(type="text", text_delta="done")), - StreamTaskMessageDone(type="done", index=3), - ], - ) -) - - -@pytest.mark.parametrize("fixture", _OPENAI_FIXTURES, ids=lambda f: f.name) -@pytest.mark.asyncio -async def test_openai_cross_channel_equivalence(fixture: Fixture) -> None: - """Assert that yield_events and auto_send produce equivalent logical - deliveries and identical span signals for every OpenAI fixture. - - This is the cross-channel guarantee: the two delivery adapters agree on - WHAT was delivered (logical content) and HOW spans were derived, even - though their streaming-envelope shapes differ (Full vs Start+Done for tool - messages). - - The span signals are the ones each channel's tracer ACTUALLY recorded while - delivering, not a re-derivation, so a regression where one channel skips - deriver.observe() for some event type is caught here. - """ - yield_deliveries, auto_deliveries, yield_spans, auto_spans = await run_cross_channel_conformance(fixture) - - assert yield_deliveries == auto_deliveries, ( - f"[{fixture.name}] logical deliveries differ:\n yield: {yield_deliveries}\n auto_send: {auto_deliveries}" - ) - assert yield_spans == auto_spans, ( - f"[{fixture.name}] span signals differ:\n yield: {yield_spans}\n auto_send: {auto_spans}" - ) diff --git a/tests/lib/core/harness/conformance/test_pydantic_ai_conformance.py b/tests/lib/core/harness/conformance/test_pydantic_ai_conformance.py deleted file mode 100644 index 5d9952334..000000000 --- a/tests/lib/core/harness/conformance/test_pydantic_ai_conformance.py +++ /dev/null @@ -1,187 +0,0 @@ -"""Cross-channel conformance fixtures derived from real pydantic-ai event sequences. - -Each fixture is built by running a pydantic_ai event stream through PydanticAITurn -and collecting the canonical StreamTaskMessage* output. These canonical event lists are -then registered with the conformance runner and exercised by the cross-channel test -(yield_events vs auto_send). - -Streamed tool requests ----------------------- -The pydantic-ai stream emits a tool REQUEST as Start + ToolRequestDelta + Done (not a -Full event). Both the conformance runner and auto_send deliver the -Start+Delta+Done(tool_request) shape, so the cross-channel test asserts full -delivery-equivalence for streamed tool requests. The fixtures below retain the -ToolRequestDelta events as the streamed tool-request inputs. -""" - -from __future__ import annotations - -from typing import Any, AsyncIterator - -import pytest -from pydantic_ai.messages import ( - TextPart, - PartEndEvent, - ThinkingPart, - ToolCallPart, - TextPartDelta, - PartDeltaEvent, - PartStartEvent, - ToolReturnPart, - ThinkingPartDelta, - ToolCallPartDelta, - FunctionToolResultEvent, -) - -from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn - -from .runner import ( - Fixture, - register, - run_pure_async, - run_cross_channel_conformance, -) - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -async def _aiter(events: list[Any]) -> AsyncIterator[Any]: - for e in events: - yield e - - -async def _canonical(pydantic_events: list[Any]) -> list[Any]: - """Run pydantic_ai events through PydanticAITurn and collect the output. - - The output equals the bare convert_pydantic_ai_to_agentex_events output. - """ - turn = PydanticAITurn(_aiter(pydantic_events), model=None) - return [e async for e in turn.events] - - -def _build_fixtures() -> list[Fixture]: - """Build all pydantic-ai conformance fixtures synchronously at import time. - - Uses the loop-free ``run_pure_async`` driver rather than ``asyncio.run()``, - which would raise under an already-running loop (programmatic pytest, - notebooks) since this runs during module import. - """ - - # ------------------------------------------------------------------ # - # 1. Text-only run: simple streaming text response. - # ------------------------------------------------------------------ # - text_only_pydantic = [ - PartStartEvent(index=0, part=TextPart(content="")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="Hello, ")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="world!")), - PartEndEvent(index=0, part=TextPart(content="Hello, world!")), - ] - - # ------------------------------------------------------------------ # - # 2. Single tool call + tool response. - # The canonical stream emits Start+ToolRequestDelta+Done for the request - # and Full(ToolResponseContent) for the response. Both are asserted - # delivery-equivalent cross-channel (see the module docstring). - # ------------------------------------------------------------------ # - tool_call_pydantic = [ - PartStartEvent( - index=0, - part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="call_01"), - ), - PartDeltaEvent( - index=0, - delta=ToolCallPartDelta(args_delta='{"city":"Paris"}', tool_call_id="call_01"), - ), - PartEndEvent( - index=0, - part=ToolCallPart(tool_name="get_weather", args='{"city":"Paris"}', tool_call_id="call_01"), - ), - FunctionToolResultEvent( - part=ToolReturnPart(tool_name="get_weather", content="Sunny, 22C", tool_call_id="call_01"), - ), - ] - - # ------------------------------------------------------------------ # - # 3. Reasoning/thinking block: produces ReasoningContent Start+Delta+Done. - # ------------------------------------------------------------------ # - reasoning_pydantic = [ - PartStartEvent(index=0, part=ThinkingPart(content="")), - PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta="First, let me think...")), - PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta=" Then conclude.")), - PartEndEvent(index=0, part=ThinkingPart(content="First, let me think... Then conclude.")), - ] - - # ------------------------------------------------------------------ # - # 4. Multi-step run: text -> tool call + response -> text. - # Pydantic AI restarts part indices at 0 for each model response; the - # converter assigns globally-monotonic indices to Agentex messages. - # ------------------------------------------------------------------ # - multi_step_pydantic = [ - # First model turn: text then tool call - PartStartEvent(index=0, part=TextPart(content="")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="Let me check the weather.")), - PartEndEvent(index=0, part=TextPart(content="Let me check the weather.")), - PartStartEvent( - index=1, - part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="call_ms1"), - ), - PartDeltaEvent( - index=1, - delta=ToolCallPartDelta(args_delta='{"city":"London"}', tool_call_id="call_ms1"), - ), - PartEndEvent( - index=1, - part=ToolCallPart(tool_name="get_weather", args='{"city":"London"}', tool_call_id="call_ms1"), - ), - FunctionToolResultEvent( - part=ToolReturnPart(tool_name="get_weather", content="Cloudy, 15C", tool_call_id="call_ms1"), - ), - # Second model turn: text response (pydantic restarts index at 0) - PartStartEvent(index=0, part=TextPart(content="")), - PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="It's cloudy and 15C in London.")), - PartEndEvent(index=0, part=TextPart(content="It's cloudy and 15C in London.")), - ] - - text_only_events = run_pure_async(_canonical(text_only_pydantic)) - tool_call_events = run_pure_async(_canonical(tool_call_pydantic)) - reasoning_events = run_pure_async(_canonical(reasoning_pydantic)) - multi_step_events = run_pure_async(_canonical(multi_step_pydantic)) - - return [ - Fixture(name="pydantic-ai-text-only", events=text_only_events), - Fixture(name="pydantic-ai-single-tool-call", events=tool_call_events), - Fixture(name="pydantic-ai-reasoning-block", events=reasoning_events), - Fixture(name="pydantic-ai-multi-step", events=multi_step_events), - ] - - -_FIXTURES: list[Fixture] = _build_fixtures() - -for _f in _FIXTURES: - register(_f) - - -# --------------------------------------------------------------------------- -# Cross-channel conformance: logical equivalence + span equivalence -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize("fixture", _FIXTURES, ids=lambda f: f.name) -@pytest.mark.asyncio -async def test_cross_channel_equivalence(fixture: Fixture) -> None: - """Assert that yield_events and auto_send produce equivalent logical - deliveries and identical span signals for each pydantic-ai fixture. - - See runner.py for the full contract, including streamed-tool-request - delivery equivalence. - """ - yield_deliveries, auto_deliveries, yield_spans, auto_spans = await run_cross_channel_conformance(fixture) - - assert yield_deliveries == auto_deliveries, ( - f"[{fixture.name}] logical deliveries differ:\n yield: {yield_deliveries}\n auto_send: {auto_deliveries}" - ) - assert yield_spans == auto_spans, ( - f"[{fixture.name}] span signals differ:\n yield: {yield_spans}\n auto_send: {auto_spans}" - ) diff --git a/tests/lib/core/harness/test_auto_send.py b/tests/lib/core/harness/test_auto_send.py deleted file mode 100644 index 8133a488c..000000000 --- a/tests/lib/core/harness/test_auto_send.py +++ /dev/null @@ -1,480 +0,0 @@ -"""Tests for auto_send delivery adapter. - -The fake mirrors the real StreamingTaskMessageContext API exactly: -- streaming_task_message_context(...) returns a context object (synchronously) -- open the context via __aenter__ (returns self after creating the task message) -- stream deltas via ctx.stream_update(StreamTaskMessageDelta(...)) -- close via ctx.close() (NOT __aexit__) - -This mirrors _langgraph_async.py lines 62-78 and 100-127. -""" - -from datetime import datetime - -import pytest - -from agentex.types.task_message import TaskMessage -from agentex.types.text_content import TextContent -from agentex.lib.core.harness.tracer import SpanTracer -from agentex.types.task_message_delta import TextDelta -from agentex.types.tool_request_delta import ToolRequestDelta -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.lib.core.harness.auto_send import auto_send -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent - -from ._fakes import FakeTracing - - -class _FakeCtx: - """Mirrors StreamingTaskMessageContext: __aenter__ opens (returns self with task_message set), - close() closes. stream_update records the call. - - task_message is a real TaskMessage instance so that auto_send can use it - as parent_task_message in StreamTaskMessageDelta without Pydantic validation errors. - """ - - def __init__(self, sink, content_type, initial_content): - self.sink = sink - self.content_type = content_type - # Real TaskMessage so StreamTaskMessageDelta(parent_task_message=...) passes validation - self.task_message = TaskMessage(id="msg-1", task_id="task1", content=initial_content) - - async def __aenter__(self): - self.sink.append(("open", self.content_type)) - return self - - async def __aexit__(self, *a): - # __aexit__ delegates to close in the real impl; keep for safety - await self.close() - return False - - async def close(self): - self.sink.append(("close", self.content_type)) - - async def stream_update(self, update): - self.sink.append(("update", update)) - return update - - -class _FakeStreaming: - """Mirrors StreamingService: streaming_task_message_context returns a context object.""" - - def __init__(self): - self.sink = [] - self.recorded_created_at: list[datetime | None] = [] - - def streaming_task_message_context(self, task_id, initial_content, streaming_mode="coalesced", created_at=None): - ctype = getattr(initial_content, "type", None) - self.sink.append(("ctx", ctype)) - self.recorded_created_at.append(created_at) - return _FakeCtx(self.sink, ctype, initial_content) - - -async def _gen(events): - for e in events: - yield e - - -# --------------------------------------------------------------------------- -# Test 1: text streaming โ€” open, stream deltas, close; return accumulated text -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_auto_send_streams_text_and_returns_final_text(): - streaming = _FakeStreaming() - events = [ - StreamTaskMessageStart( - type="start", - index=0, - content=TextContent(type="text", author="agent", content=""), - ), - StreamTaskMessageDelta( - type="delta", - index=0, - delta=TextDelta(type="text", text_delta="Hel"), - ), - StreamTaskMessageDelta( - type="delta", - index=0, - delta=TextDelta(type="text", text_delta="lo"), - ), - StreamTaskMessageDone(type="done", index=0), - ] - result = await auto_send(_gen(events), task_id="task1", tracer=None, streaming=streaming) - - assert result.final_text == "Hello" - - kinds = [s[0] for s in streaming.sink] - # A context was created for the text content - assert kinds[0] == "ctx" - # It was opened and closed - assert "open" in kinds - assert "close" in kinds - # Exactly two updates were streamed (one per delta) - updates = [s for s in streaming.sink if s[0] == "update"] - assert len(updates) == 2 - - -# --------------------------------------------------------------------------- -# Test 2: tool_request Full + tool_response Full โ€” each posts one full message -# (open context with the content, no deltas, close immediately) -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_auto_send_posts_full_tool_messages(): - streaming = _FakeStreaming() - events = [ - # Two Full events post two messages (open+close immediately, no deltas). - StreamTaskMessageFull( - type="full", - index=0, - content=ToolRequestContent( - type="tool_request", - author="agent", - tool_call_id="c1", - name="Bash", - arguments={"cmd": "ls"}, - ), - ), - StreamTaskMessageFull( - type="full", - index=1, - content=ToolResponseContent( - type="tool_response", - author="agent", - tool_call_id="c1", - name="Bash", - content="file.py", - ), - ), - ] - result = await auto_send(_gen(events), task_id="task1", tracer=None, streaming=streaming) - - assert result.final_text == "" - - # Each Full event opens and closes exactly one context. - ctx_events = [s for s in streaming.sink if s[0] == "ctx"] - assert len(ctx_events) == 2 - content_types = [s[1] for s in ctx_events] - assert content_types == ["tool_request", "tool_response"] - - # Each context is opened and closed - opens = [s for s in streaming.sink if s[0] == "open"] - closes = [s for s in streaming.sink if s[0] == "close"] - assert len(opens) == 2 - assert len(closes) == 2 - - # No stream_update calls (full messages have no deltas) - updates = [s for s in streaming.sink if s[0] == "update"] - assert len(updates) == 0 - - -# --------------------------------------------------------------------------- -# Test 3: tracing โ€” spans are derived and handed to the tracer -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_auto_send_derives_tool_spans_via_tracer(): - fake_tracing = FakeTracing() - tracer = SpanTracer(trace_id="t", parent_span_id="p", tracing=fake_tracing) - streaming = _FakeStreaming() - - events = [ - StreamTaskMessageStart( - type="start", - index=0, - content=ToolRequestContent( - type="tool_request", - author="agent", - tool_call_id="c1", - name="Bash", - arguments={}, - ), - ), - StreamTaskMessageDone(type="done", index=0), - StreamTaskMessageFull( - type="full", - index=1, - content=ToolResponseContent( - type="tool_response", - author="agent", - tool_call_id="c1", - name="Bash", - content="ok", - ), - ), - ] - - result = await auto_send(_gen(events), task_id="task1", tracer=tracer, streaming=streaming) - - assert result.final_text == "" - assert fake_tracing.started_names == ["Bash"] - # String tool output is wrapped in a dict (SGP spans require an object). - assert fake_tracing.ended_outputs == [{"output": "ok"}] - - -# --------------------------------------------------------------------------- -# Test 4: text followed by a tool Full โ€” text context is closed before Full -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_auto_send_closes_text_context_before_full_message(): - streaming = _FakeStreaming() - events = [ - StreamTaskMessageStart( - type="start", - index=0, - content=TextContent(type="text", author="agent", content=""), - ), - StreamTaskMessageDelta( - type="delta", - index=0, - delta=TextDelta(type="text", text_delta="Hi"), - ), - StreamTaskMessageDone(type="done", index=0), - StreamTaskMessageFull( - type="full", - index=1, - content=ToolRequestContent( - type="tool_request", - author="agent", - tool_call_id="c2", - name="read_file", - arguments={}, - ), - ), - ] - result = await auto_send(_gen(events), task_id="task1", tracer=None, streaming=streaming) - assert result.final_text == "Hi" - - # Verify ordering: text ctx opens, updates, closes; then tool_request ctx opens, closes - event_sequence = [(s[0], s[1]) for s in streaming.sink] - text_open_idx = next(i for i, s in enumerate(event_sequence) if s == ("open", "text")) - text_close_idx = next(i for i, s in enumerate(event_sequence) if s == ("close", "text")) - tool_open_idx = next(i for i, s in enumerate(event_sequence) if s == ("open", "tool_request")) - assert text_open_idx < text_close_idx < tool_open_idx - - -# --------------------------------------------------------------------------- -# Test 5: midstream error โ€” propagates AND the open context is closed (finally) -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_open_context_closed_on_midstream_error(): - streaming = _FakeStreaming() - - async def _exploding_gen(): - yield StreamTaskMessageStart( - type="start", - index=0, - content=TextContent(type="text", author="agent", content=""), - ) - raise RuntimeError("boom") - - with pytest.raises(RuntimeError, match="boom"): - await auto_send(_exploding_gen(), task_id="task1", tracer=None, streaming=streaming) - - # The text context that was opened mid-stream was closed by the finally block. - assert ("open", "text") in [(s[0], s[1]) for s in streaming.sink] - assert ("close", "text") in [(s[0], s[1]) for s in streaming.sink] - - -# --------------------------------------------------------------------------- -# Test 6: streamed tool_request delivered -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_auto_send_streams_tool_request(): - """A Start(ToolRequestContent) MUST open a streaming context.""" - streaming = _FakeStreaming() - events = [ - StreamTaskMessageStart( - type="start", - index=0, - content=ToolRequestContent( - type="tool_request", - author="agent", - tool_call_id="c_tool", - name="Bash", - arguments={}, - ), - ), - StreamTaskMessageDelta( - type="delta", - index=0, - delta=ToolRequestDelta( - type="tool_request", - tool_call_id="c_tool", - name="Bash", - arguments_delta='{"cmd": "ls"}', - ), - ), - StreamTaskMessageDone(type="done", index=0), - ] - result = await auto_send(_gen(events), task_id="task1", tracer=None, streaming=streaming) - - assert result.final_text == "" - - ctx_events = [s for s in streaming.sink if s[0] == "ctx"] - assert len(ctx_events) == 1 - assert ctx_events[0][1] == "tool_request" - - opens = [s for s in streaming.sink if s[0] == "open"] - closes = [s for s in streaming.sink if s[0] == "close"] - assert len(opens) == 1 - assert len(closes) == 1 - - updates = [s for s in streaming.sink if s[0] == "update"] - assert len(updates) == 1 - - -# --------------------------------------------------------------------------- -# Test 7: interleaved indexes route correctly -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_auto_send_interleaved_indexes_route_correctly(): - """Deltas must be routed to the correct index-keyed context.""" - streaming = _FakeStreaming() - events = [ - StreamTaskMessageStart( - type="start", - index=0, - content=TextContent(type="text", author="agent", content=""), - ), - StreamTaskMessageStart( - type="start", - index=1, - content=TextContent(type="text", author="agent", content=""), - ), - StreamTaskMessageDelta( - type="delta", - index=0, - delta=TextDelta(type="text", text_delta="A"), - ), - StreamTaskMessageDelta( - type="delta", - index=1, - delta=TextDelta(type="text", text_delta="B"), - ), - StreamTaskMessageDone(type="done", index=0), - StreamTaskMessageDone(type="done", index=1), - ] - result = await auto_send(_gen(events), task_id="task1", tracer=None, streaming=streaming) - - ctx_events = [s for s in streaming.sink if s[0] == "ctx"] - assert len(ctx_events) == 2 - - opens = [s for s in streaming.sink if s[0] == "open"] - assert len(opens) == 2 - - updates = [s for s in streaming.sink if s[0] == "update"] - assert len(updates) == 2 - - update_deltas = [s[1].delta for s in streaming.sink if s[0] == "update"] - text_deltas = [d.text_delta for d in update_deltas if isinstance(d, TextDelta)] - assert set(text_deltas) == {"A", "B"} - - -# --------------------------------------------------------------------------- -# Test 8: final_text returns last text segment for multi-step -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_auto_send_final_text_last_segment(): - """final_text must be the LAST text segment, not accumulated across all turns.""" - streaming = _FakeStreaming() - events = [ - StreamTaskMessageStart( - type="start", - index=0, - content=TextContent(type="text", author="agent", content=""), - ), - StreamTaskMessageDelta( - type="delta", - index=0, - delta=TextDelta(type="text", text_delta="First"), - ), - StreamTaskMessageDone(type="done", index=0), - StreamTaskMessageStart( - type="start", - index=1, - content=TextContent(type="text", author="agent", content=""), - ), - StreamTaskMessageDelta( - type="delta", - index=1, - delta=TextDelta(type="text", text_delta="Second"), - ), - StreamTaskMessageDone(type="done", index=1), - ] - result = await auto_send(_gen(events), task_id="task1", tracer=None, streaming=streaming) - assert result.final_text == "Second" - - -# --------------------------------------------------------------------------- -# Test 9: Full(TextContent) contributes to final_text -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_auto_send_full_text_content_sets_final_text(): - """A Full(TextContent) must contribute its text to final_text.""" - streaming = _FakeStreaming() - events = [ - StreamTaskMessageFull( - type="full", - index=0, - content=TextContent(type="text", author="agent", content="hello"), - ), - ] - result = await auto_send(_gen(events), task_id="task1", tracer=None, streaming=streaming) - assert result.final_text == "hello" - - -# --------------------------------------------------------------------------- -# Test 10: created_at is forwarded to streaming context -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_auto_send_created_at_forwarded(): - """created_at must be forwarded to every streaming_task_message_context call.""" - streaming = _FakeStreaming() - dt = datetime(2025, 1, 15, 12, 0, 0) - events = [ - StreamTaskMessageStart( - type="start", - index=0, - content=TextContent(type="text", author="agent", content=""), - ), - StreamTaskMessageDone(type="done", index=0), - StreamTaskMessageFull( - type="full", - index=1, - content=ToolRequestContent( - type="tool_request", - author="agent", - tool_call_id="c_ts", - name="Bash", - arguments={}, - ), - ), - ] - await auto_send(_gen(events), task_id="task1", tracer=None, streaming=streaming, created_at=dt) - - assert all(ts == dt for ts in streaming.recorded_created_at) diff --git a/tests/lib/core/harness/test_emitter.py b/tests/lib/core/harness/test_emitter.py deleted file mode 100644 index 3f70660ec..000000000 --- a/tests/lib/core/harness/test_emitter.py +++ /dev/null @@ -1,142 +0,0 @@ -import pytest - -from agentex.types.task_message import TaskMessage -from agentex.types.text_content import TextContent -from agentex.lib.core.harness.types import TurnUsage -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.types.task_message_delta import TextDelta -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) - -from ._fakes import FakeTracing - - -class _FakeCtx: - """Minimal StreamingTaskMessageContext fake (see test_auto_send.py).""" - - def __init__(self, sink, content_type, initial_content): - self.sink = sink - self.content_type = content_type - self.task_message = TaskMessage(id="msg-1", task_id="task1", content=initial_content) - - async def __aenter__(self): - self.sink.append(("open", self.content_type)) - return self - - async def __aexit__(self, *a): - await self.close() - return False - - async def close(self): - self.sink.append(("close", self.content_type)) - - async def stream_update(self, update): - self.sink.append(("update", update)) - return update - - -class _FakeStreaming: - def __init__(self): - self.sink = [] - - def streaming_task_message_context(self, task_id, initial_content, streaming_mode="coalesced", created_at=None): - ctype = getattr(initial_content, "type", None) - self.sink.append(("ctx", ctype)) - return _FakeCtx(self.sink, ctype, initial_content) - - -class _Turn: - def __init__(self, events_list, usage): - self._events_list = events_list - self._usage = usage - - @property - async def events(self): - for e in self._events_list: - yield e - - def usage(self): - return self._usage - - -@pytest.mark.asyncio -async def test_emitter_yield_mode_passes_through(): - events = [ - StreamTaskMessageStart(type="start", index=0, content=TextContent(type="text", author="agent", content="hi")), - StreamTaskMessageDone(type="done", index=0), - ] - turn = _Turn(events, TurnUsage(model="m")) - emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) - out = [e async for e in emitter.yield_turn(turn)] - assert out == events - - -@pytest.mark.asyncio -async def test_emitter_tracing_default_on_when_trace_id_present(): - # Inject a fake tracing backend so the test env doesn't need temporalio. - # This exercises the default-on path (tracer=None) when trace_id is truthy. - emitter = UnifiedEmitter(task_id="t", trace_id="trace1", parent_span_id="p", tracing=FakeTracing()) - assert emitter.tracer is not None - - -@pytest.mark.asyncio -async def test_emitter_tracing_overridable_off(): - emitter = UnifiedEmitter(task_id="t", trace_id="trace1", parent_span_id="p", tracer=False) - assert emitter.tracer is None - - -@pytest.mark.asyncio -async def test_emitter_auto_send_turn_returns_usage(): - usage = TurnUsage(model="m", input_tokens=5) - events = [ - StreamTaskMessageStart(type="start", index=0, content=TextContent(type="text", author="agent", content="")), - StreamTaskMessageDelta(type="delta", index=0, delta=TextDelta(type="text", text_delta="Hello")), - StreamTaskMessageDone(type="done", index=0), - ] - turn = _Turn(events, usage) - fake = _FakeStreaming() - emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None, streaming=fake) - result = await emitter.auto_send_turn(turn) - assert result.usage == usage - assert result.final_text == "Hello" - - -class _ContractTurn: - """A turn that honors the single-pass contract: usage() is the empty default - UNTIL `events` is exhausted, then the real usage (this is how real harness - turns behave โ€” they populate usage while the stream is consumed).""" - - def __init__(self, events_list, real_usage): - self._events_list = events_list - self._real_usage = real_usage - self._exhausted = False - - @property - async def events(self): - for e in self._events_list: - yield e - self._exhausted = True - - def usage(self): - return self._real_usage if self._exhausted else TurnUsage(model="m") - - -@pytest.mark.asyncio -async def test_emitter_auto_send_turn_reads_usage_after_exhaustion(): - # Regression: auto_send_turn must read turn.usage() AFTER consuming the - # stream, not eagerly when building the auto_send call (which would capture - # the empty default and lose real token usage on the auto_send path). - real_usage = TurnUsage(model="m", input_tokens=11, output_tokens=22, total_tokens=33, num_llm_calls=2) - events = [ - StreamTaskMessageStart(type="start", index=0, content=TextContent(type="text", author="agent", content="")), - StreamTaskMessageDelta(type="delta", index=0, delta=TextDelta(type="text", text_delta="hi")), - StreamTaskMessageDone(type="done", index=0), - ] - turn = _ContractTurn(events, real_usage) - emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None, streaming=_FakeStreaming()) - result = await emitter.auto_send_turn(turn) - assert result.usage == real_usage - assert result.usage.input_tokens == 11 and result.usage.total_tokens == 33 diff --git a/tests/lib/core/harness/test_harness_claude_code_async.py b/tests/lib/core/harness/test_harness_claude_code_async.py deleted file mode 100644 index c622d25c1..000000000 --- a/tests/lib/core/harness/test_harness_claude_code_async.py +++ /dev/null @@ -1,248 +0,0 @@ -"""Integration test: async (Redis-streaming) channel with a claude-code turn. - -Exercises the unified harness surface (UnifiedEmitter.auto_send_turn + ClaudeCodeTurn) -with hand-built claude-code ``stream-json`` envelopes and a fake streaming -backend so the test runs fully offline (no claude-code CLI subprocess, no -Redis, no Agentex server). - -Native envelope shapes are copied verbatim from the claude-code turn test and -conformance fixtures (assistant tool_use -> Start(ToolRequestContent)+Done; -user tool_result -> Full(ToolResponseContent); assistant text -> -Start(TextContent)+Delta+Done; result envelope -> usage). - -What is tested --------------- -- auto_send pushes the correct message contexts: tool_request + tool_response - + text (in that order). -- TurnResult.final_text equals the final assistant text. -- TurnResult.usage reflects the claude-code ``result`` envelope (input/output - tokens, cost, num_llm_calls from num_turns). -- With a SpanTracer + fake tracing, a tool span is derived on the async path. - -What is NOT covered without live infrastructure ------------------------------------------------ -- Actual Redis streaming. -- The ACP on_task_event_send / on_task_create / on_task_cancel lifecycle. -- A real claude-code CLI subprocess / live model behaviour. - -See also: test_harness_claude_code_sync.py and test_harness_claude_code_temporal.py. -""" - -from __future__ import annotations - -from typing import Any, AsyncIterator - -import pytest - -from agentex.types.task_message import TaskMessage -from agentex.lib.core.harness.types import TurnResult -from agentex.lib.core.harness.tracer import SpanTracer -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.lib.adk._modules._claude_code_turn import ClaudeCodeTurn - -from ._fakes import FakeTracing - -# --------------------------------------------------------------------------- -# Native claude-code envelope fixtures -# --------------------------------------------------------------------------- - - -def _tool_then_text_envelopes() -> list[dict[str, Any]]: - return [ - { - "type": "assistant", - "message": { - "content": [ - { - "type": "tool_use", - "id": "call_read", - "name": "Read", - "input": {"path": "/workspace/README.md"}, - } - ] - }, - }, - { - "type": "user", - "message": { - "content": [ - { - "type": "tool_result", - "tool_use_id": "call_read", - "content": "# My Project โ€” temperature 72F", - } - ] - }, - }, - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "The project file says 72F."}]}, - }, - { - "type": "result", - "usage": {"input_tokens": 200, "output_tokens": 80}, - "cost_usd": 0.015, - "num_turns": 2, - }, - ] - - -async def _aiter(envelopes: list[dict[str, Any]]) -> AsyncIterator[dict[str, Any]]: - for e in envelopes: - yield e - - -# --------------------------------------------------------------------------- -# Fake streaming backend -# --------------------------------------------------------------------------- - - -class _FakeCtx: - def __init__(self, sink: list[Any], ctype: str, initial_content: Any) -> None: - self.sink = sink - self.ctype = ctype - self.task_message = TaskMessage(id="msg-1", task_id="task1", content=initial_content) - - async def __aenter__(self) -> "_FakeCtx": - self.sink.append(("open", self.ctype, self.task_message.content)) - return self - - async def __aexit__(self, *args: Any) -> bool: - await self.close() - return False - - async def close(self) -> None: - self.sink.append(("close", self.ctype)) - - async def stream_update(self, update: Any) -> Any: - self.sink.append(("delta", self.ctype, update)) - return update - - -class _FakeStreaming: - def __init__(self) -> None: - self.sink: list[Any] = [] - self.messages_opened: list[Any] = [] - - def streaming_task_message_context( - self, - task_id: str, - initial_content: Any, - streaming_mode: str = "coalesced", - created_at: Any = None, - ) -> _FakeCtx: - ctype = getattr(initial_content, "type", None) or "" - self.messages_opened.append(initial_content) - return _FakeCtx(self.sink, ctype, initial_content) - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -async def _run_auto_send_turn( - envelopes: list[dict[str, Any]], - trace_id: str | None = None, - parent_span_id: str | None = None, - fake_tracing: FakeTracing | None = None, -) -> tuple[TurnResult, _FakeStreaming]: - fake_streaming = _FakeStreaming() - tracer: SpanTracer | bool | None = None - if trace_id and fake_tracing is not None: - tracer = SpanTracer( - trace_id=trace_id, - parent_span_id=parent_span_id, - task_id="task1", - tracing=fake_tracing, - ) - - turn = ClaudeCodeTurn(_aiter(envelopes)) - emitter = UnifiedEmitter( - task_id="task1", - trace_id=trace_id, - parent_span_id=parent_span_id, - tracer=tracer if tracer is not None else False, - streaming=fake_streaming, - ) - result = await emitter.auto_send_turn(turn) - return result, fake_streaming - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -class TestAsyncAutoSendMessageOrder: - async def test_tool_request_pushed_before_tool_response(self) -> None: - _, fake_streaming = await _run_auto_send_turn(_tool_then_text_envelopes()) - types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] - assert "tool_request" in types - assert "tool_response" in types - assert types.index("tool_request") < types.index("tool_response") - - async def test_text_pushed_last(self) -> None: - _, fake_streaming = await _run_auto_send_turn(_tool_then_text_envelopes()) - types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] - assert types[-1] == "text", f"Expected last type=text, got {types}" - - -class TestAsyncAutoSendContent: - async def test_tool_request_content(self) -> None: - _, fake_streaming = await _run_auto_send_turn(_tool_then_text_envelopes()) - tool_reqs = [m for m in fake_streaming.messages_opened if isinstance(m, ToolRequestContent)] - assert len(tool_reqs) == 1 - assert tool_reqs[0].name == "Read" - - async def test_tool_response_content(self) -> None: - _, fake_streaming = await _run_auto_send_turn(_tool_then_text_envelopes()) - tool_resps = [m for m in fake_streaming.messages_opened if isinstance(m, ToolResponseContent)] - assert len(tool_resps) == 1 - assert "72F" in str(tool_resps[0].content) - - async def test_tool_call_ids_match(self) -> None: - _, fake_streaming = await _run_auto_send_turn(_tool_then_text_envelopes()) - tool_req = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolRequestContent)) - tool_resp = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolResponseContent)) - assert tool_req.tool_call_id == tool_resp.tool_call_id == "call_read" - - -class TestAsyncAutoSendFinalTextAndUsage: - async def test_final_text_matches_last_text(self) -> None: - result, _ = await _run_auto_send_turn(_tool_then_text_envelopes()) - assert result.final_text == "The project file says 72F." - - async def test_usage_from_result_envelope(self) -> None: - """TurnResult.usage reflects the claude-code result envelope.""" - result, _ = await _run_auto_send_turn(_tool_then_text_envelopes()) - assert result.usage is not None - assert result.usage.input_tokens == 200 - assert result.usage.output_tokens == 80 - assert result.usage.total_tokens == 280 - assert result.usage.cost_usd == pytest.approx(0.015) - assert result.usage.num_llm_calls == 2 - - async def test_context_lifecycle_open_then_close(self) -> None: - _, fake_streaming = await _run_auto_send_turn(_tool_then_text_envelopes()) - opens = [e for e in fake_streaming.sink if e[0] == "open"] - closes = [e for e in fake_streaming.sink if e[0] == "close"] - assert len(opens) == len(closes) - assert len(opens) == len(fake_streaming.messages_opened) - - -class TestAsyncAutoSendSpanDerivation: - async def test_tool_span_derived_on_async_path(self) -> None: - fake_tracing = FakeTracing() - await _run_auto_send_turn( - _tool_then_text_envelopes(), - trace_id="trace1", - parent_span_id="parent", - fake_tracing=fake_tracing, - ) - assert len(fake_tracing.started) == 1 - assert fake_tracing.started[0][0] == "Read" - assert len(fake_tracing.ended) == 1 - assert "72F" in str(fake_tracing.ended[0][1]) diff --git a/tests/lib/core/harness/test_harness_claude_code_sync.py b/tests/lib/core/harness/test_harness_claude_code_sync.py deleted file mode 100644 index b53485499..000000000 --- a/tests/lib/core/harness/test_harness_claude_code_sync.py +++ /dev/null @@ -1,303 +0,0 @@ -"""Integration test: sync (HTTP-yield) channel with a claude-code turn. - -Exercises the unified harness surface (UnifiedEmitter.yield_turn + ClaudeCodeTurn) -with hand-built claude-code ``stream-json`` envelopes so the test runs fully -offline (no claude-code CLI subprocess, no API keys, no Agentex server). - -Native stream shapes ---------------------- -``ClaudeCodeTurn`` consumes an async iterator of raw claude-code stream-json -envelopes (str | dict). The envelope shapes used here are copied verbatim from -the claude-code turn test (tests/lib/adk/test_claude_code_turn.py) and the -claude-code conformance fixtures -(tests/lib/core/harness/conformance/test_claude_code_conformance.py): - - assistant text block -> Start(TextContent) + Delta + Done - assistant tool_use -> Start(ToolRequestContent) + Done - user tool_result -> Full(ToolResponseContent) - assistant thinking -> Start(ReasoningContent) + Delta + Done - -What is tested --------------- -- The sync handler forwards StreamTaskMessage* events in canonical order: - tool_request (Start+Done) -> tool_response (Full) -> text. -- The tool_response carries the tool_result content, keyed by tool_use_id. -- With a trace_id + fake tracing, the SpanDeriver opens a tool span on - Done(tool_request) and closes it on the matching Full(tool_response), and - opens/closes a reasoning span for a thinking block. - -What is NOT covered without live infrastructure ------------------------------------------------ -- Actual HTTP streaming over the ACP sync endpoint. -- A real claude-code CLI subprocess / live model behaviour. -- The full FastACP request/response lifecycle. - -See also: test_harness_claude_code_async.py and test_harness_claude_code_temporal.py. -""" - -from __future__ import annotations - -from typing import Any, AsyncIterator, override - -from agentex.lib.core.harness.types import OpenSpan, CloseSpan -from agentex.lib.core.harness.tracer import SpanTracer -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageStart, -) -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.lib.adk._modules._claude_code_turn import ClaudeCodeTurn - -from ._fakes import FakeTracing - -# --------------------------------------------------------------------------- -# Native claude-code envelope fixtures (copied from the turn + conformance tests) -# --------------------------------------------------------------------------- - - -def _tool_then_text_envelopes() -> list[dict[str, Any]]: - """tool_use -> tool_result -> final text, then a result envelope with usage.""" - return [ - { - "type": "assistant", - "message": { - "content": [ - { - "type": "tool_use", - "id": "call_read", - "name": "Read", - "input": {"path": "/workspace/README.md"}, - } - ] - }, - }, - { - "type": "user", - "message": { - "content": [ - { - "type": "tool_result", - "tool_use_id": "call_read", - "content": "# My Project โ€” temperature 72F", - } - ] - }, - }, - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "The project file says 72F."}]}, - }, - { - "type": "result", - "usage": {"input_tokens": 100, "output_tokens": 50}, - "cost_usd": 0.01, - "num_turns": 2, - }, - ] - - -def _thinking_envelopes() -> list[dict[str, Any]]: - return [ - { - "type": "assistant", - "message": { - "content": [ - {"type": "thinking", "thinking": "Let me think.\nStep 1: check the facts."}, - {"type": "text", "text": "Here is my answer."}, - ] - }, - }, - {"type": "result", "usage": {"input_tokens": 10, "output_tokens": 5}}, - ] - - -async def _aiter(envelopes: list[dict[str, Any]]) -> AsyncIterator[dict[str, Any]]: - for e in envelopes: - yield e - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -async def _run_yield_turn( - envelopes: list[dict[str, Any]], - trace_id: str | None = None, - parent_span_id: str | None = None, - fake_tracing: FakeTracing | None = None, -) -> list[Any]: - tracer: SpanTracer | bool | None = None - if trace_id and fake_tracing is not None: - tracer = SpanTracer( - trace_id=trace_id, - parent_span_id=parent_span_id, - task_id="task1", - tracing=fake_tracing, - ) - - turn = ClaudeCodeTurn(_aiter(envelopes)) - emitter = UnifiedEmitter( - task_id="task1", - trace_id=trace_id, - parent_span_id=parent_span_id, - tracer=tracer if tracer is not None else False, - ) - return [ev async for ev in emitter.yield_turn(turn)] - - -# --------------------------------------------------------------------------- -# Tests: event order and content -# --------------------------------------------------------------------------- - - -class TestSyncYieldEventOrder: - async def test_tool_request_precedes_tool_response(self) -> None: - events = await _run_yield_turn(_tool_then_text_envelopes()) - content_types = [ - getattr(getattr(ev, "content", None), "type", None) - for ev in events - if isinstance(ev, (StreamTaskMessageStart, StreamTaskMessageFull)) - ] - assert "tool_request" in content_types - assert "tool_response" in content_types - assert content_types.index("tool_request") < content_types.index("tool_response") - - async def test_text_appears_after_tool_response(self) -> None: - events = await _run_yield_turn(_tool_then_text_envelopes()) - tool_resp_pos = next( - i - for i, ev in enumerate(events) - if isinstance(ev, StreamTaskMessageFull) - and getattr(getattr(ev, "content", None), "type", None) == "tool_response" - ) - text_start_pos = next( - i - for i, ev in enumerate(events) - if isinstance(ev, StreamTaskMessageStart) and getattr(getattr(ev, "content", None), "type", None) == "text" - ) - assert tool_resp_pos < text_start_pos - - async def test_tool_response_carries_result_keyed_by_tool_use_id(self) -> None: - events = await _run_yield_turn(_tool_then_text_envelopes()) - full_responses = [ - ev.content - for ev in events - if isinstance(ev, StreamTaskMessageFull) and isinstance(getattr(ev, "content", None), ToolResponseContent) - ] - assert len(full_responses) == 1 - tool_response = full_responses[0] - assert isinstance(tool_response, ToolResponseContent) - assert tool_response.tool_call_id == "call_read" - assert "72F" in str(tool_response.content) - - async def test_tool_request_is_read(self) -> None: - events = await _run_yield_turn(_tool_then_text_envelopes()) - tool_reqs = [ - ev.content - for ev in events - if isinstance(getattr(ev, "content", None), ToolRequestContent) - ] - assert any(isinstance(c, ToolRequestContent) and c.name == "Read" for c in tool_reqs) - - async def test_every_start_has_matching_done(self) -> None: - events = await _run_yield_turn(_tool_then_text_envelopes()) - starts = {ev.index for ev in events if isinstance(ev, StreamTaskMessageStart)} - dones = {ev.index for ev in events if isinstance(ev, StreamTaskMessageDone)} - assert starts == dones, f"Unmatched Start/Done indices: starts={starts} dones={dones}" - - -# --------------------------------------------------------------------------- -# Tests: span derivation on the yield path -# --------------------------------------------------------------------------- - - -class TestSyncYieldSpanDerivation: - async def test_tool_span_opened_and_closed(self) -> None: - """Done(tool_request) opens a tool span; Full(tool_response) closes it.""" - fake_tracing = FakeTracing() - await _run_yield_turn( - _tool_then_text_envelopes(), - trace_id="trace1", - parent_span_id="parent-span", - fake_tracing=fake_tracing, - ) - assert len(fake_tracing.started) == 1 - assert len(fake_tracing.ended) == 1 - name, parent_id, _ = fake_tracing.started[0] - assert name == "Read" - assert parent_id == "parent-span" - - async def test_tool_span_output_is_tool_result(self) -> None: - fake_tracing = FakeTracing() - await _run_yield_turn( - _tool_then_text_envelopes(), - trace_id="trace1", - parent_span_id="parent-span", - fake_tracing=fake_tracing, - ) - name, output = fake_tracing.ended[0] - assert name == "Read" - assert "72F" in str(output) - - async def test_reasoning_span_for_thinking_block(self) -> None: - """A thinking block opens and closes a reasoning span.""" - fake_tracing = FakeTracing() - await _run_yield_turn( - _thinking_envelopes(), - trace_id="trace1", - parent_span_id="parent-span", - fake_tracing=fake_tracing, - ) - assert fake_tracing.started_names == ["reasoning"] - assert len(fake_tracing.ended) == 1 - - async def test_no_trace_id_means_no_spans(self) -> None: - fake_tracing = FakeTracing() - turn = ClaudeCodeTurn(_aiter(_tool_then_text_envelopes())) - emitter = UnifiedEmitter(task_id="task1", trace_id=None, parent_span_id=None, tracing=fake_tracing) - [_ async for _ in emitter.yield_turn(turn)] - assert fake_tracing.started == [] - assert fake_tracing.ended == [] - - async def test_tracer_false_suppresses_spans(self) -> None: - fake_tracing = FakeTracing() - turn = ClaudeCodeTurn(_aiter(_tool_then_text_envelopes())) - emitter = UnifiedEmitter( - task_id="task1", - trace_id="trace1", - parent_span_id="parent-span", - tracer=False, - tracing=fake_tracing, - ) - [_ async for _ in emitter.yield_turn(turn)] - assert fake_tracing.started == [] - assert fake_tracing.ended == [] - - async def test_span_signal_types(self) -> None: - received_signals: list[Any] = [] - - class _RecordingTracer(SpanTracer): - @override - async def handle(self, signal: Any) -> None: - received_signals.append(signal) - await super().handle(signal) - - fake_tracing = FakeTracing() - tracer = _RecordingTracer( - trace_id="trace1", - parent_span_id="parent", - task_id="task1", - tracing=fake_tracing, - ) - turn = ClaudeCodeTurn(_aiter(_tool_then_text_envelopes())) - emitter = UnifiedEmitter(task_id="task1", trace_id="trace1", parent_span_id="parent", tracer=tracer) - [_ async for _ in emitter.yield_turn(turn)] - - tool_signals = [s for s in received_signals if getattr(s, "name", None) == "Read"] - assert len(tool_signals) >= 1 - assert isinstance(received_signals[0], OpenSpan) - assert any(isinstance(s, CloseSpan) for s in received_signals) diff --git a/tests/lib/core/harness/test_harness_claude_code_temporal.py b/tests/lib/core/harness/test_harness_claude_code_temporal.py deleted file mode 100644 index b643f0d20..000000000 --- a/tests/lib/core/harness/test_harness_claude_code_temporal.py +++ /dev/null @@ -1,183 +0,0 @@ -"""Integration test: Temporal channel with a claude-code turn, offline. - -The claude-code tap is a pure library adapter (no Temporal-specific helper such -as langgraph's ``stream_langgraph_events``). In a Temporal deployment the -claude-code CLI runs inside a Temporal activity and the resulting canonical -stream is delivered via the SAME ``UnifiedEmitter.auto_send_turn`` path used by -the non-temporal async channel. The only temporal-specific concern at the -harness boundary is that the activity stamps messages with a deterministic -``created_at`` (e.g. ``workflow.now()``) for replay determinism. - -This suite therefore exercises the auto_send path inside an activity-style call -plus the temporal-only contract: ``created_at`` is threaded through to every -streaming context. The native claude-code envelope shapes are copied verbatim -from the claude-code turn test / conformance fixtures. - -What is tested --------------- -- The canonical message sequence (tool_request -> tool_response -> text) is - delivered via auto_send_turn, exactly as inside a Temporal activity. -- ``created_at`` passed to ``auto_send_turn`` is forwarded to every - ``streaming_task_message_context`` call (deterministic timestamping). -- Final text + usage from the result envelope are returned. - -What is NOT covered without live infrastructure ------------------------------------------------ -- Temporal scheduling / durability / replay behaviour. -- Redis streaming (requires a running Redis instance). -- A real claude-code CLI subprocess / live model behaviour. - -See also: test_harness_claude_code_sync.py and test_harness_claude_code_async.py. -""" - -from __future__ import annotations - -from typing import Any, AsyncIterator -from datetime import datetime, timezone - -from agentex.types.task_message import TaskMessage -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.lib.adk._modules._claude_code_turn import ClaudeCodeTurn - - -def _tool_then_text_envelopes() -> list[dict[str, Any]]: - return [ - { - "type": "assistant", - "message": { - "content": [ - { - "type": "tool_use", - "id": "call_read", - "name": "Read", - "input": {"path": "/workspace/README.md"}, - } - ] - }, - }, - { - "type": "user", - "message": { - "content": [ - {"type": "tool_result", "tool_use_id": "call_read", "content": "# My Project โ€” 72F"} - ] - }, - }, - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "The project file says 72F."}]}, - }, - {"type": "result", "usage": {"input_tokens": 50, "output_tokens": 20}, "num_turns": 2}, - ] - - -async def _aiter(envelopes: list[dict[str, Any]]) -> AsyncIterator[dict[str, Any]]: - for e in envelopes: - yield e - - -# --------------------------------------------------------------------------- -# Fake streaming backend that records created_at -# --------------------------------------------------------------------------- - - -class _FakeCtx: - def __init__(self, ctype: str, initial_content: Any) -> None: - self.ctype = ctype - self.task_message = TaskMessage(id="msg-1", task_id="task1", content=initial_content) - - async def __aenter__(self) -> "_FakeCtx": - return self - - async def __aexit__(self, *args: Any) -> bool: - await self.close() - return False - - async def close(self) -> None: - pass - - async def stream_update(self, update: Any) -> Any: - return update - - -class _FakeStreaming: - def __init__(self) -> None: - self.messages_opened: list[Any] = [] - self.created_ats: list[Any] = [] - - def streaming_task_message_context( - self, - task_id: str, - initial_content: Any, - streaming_mode: str = "coalesced", - created_at: Any = None, - ) -> _FakeCtx: - ctype = getattr(initial_content, "type", None) or "" - self.messages_opened.append(initial_content) - self.created_ats.append(created_at) - return _FakeCtx(ctype, initial_content) - - -async def _run_activity( - envelopes: list[dict[str, Any]], created_at: datetime | None -) -> tuple[Any, _FakeStreaming]: - fake_streaming = _FakeStreaming() - turn = ClaudeCodeTurn(_aiter(envelopes)) - emitter = UnifiedEmitter( - task_id="task1", - trace_id=None, - parent_span_id=None, - tracer=False, - streaming=fake_streaming, - ) - result = await emitter.auto_send_turn(turn, created_at=created_at) - return result, fake_streaming - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -class TestTemporalActivityDelivery: - async def test_canonical_sequence_delivered(self) -> None: - _, fake_streaming = await _run_activity(_tool_then_text_envelopes(), created_at=None) - types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] - assert "tool_request" in types - assert "tool_response" in types - assert types.index("tool_request") < types.index("tool_response") - assert types[-1] == "text" - - async def test_tool_round_trip_keyed_correctly(self) -> None: - _, fake_streaming = await _run_activity(_tool_then_text_envelopes(), created_at=None) - tool_req = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolRequestContent)) - tool_resp = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolResponseContent)) - assert tool_req.tool_call_id == tool_resp.tool_call_id == "call_read" - - async def test_final_text_and_usage(self) -> None: - result, _ = await _run_activity(_tool_then_text_envelopes(), created_at=None) - assert result.final_text == "The project file says 72F." - assert result.usage.input_tokens == 50 - assert result.usage.num_llm_calls == 2 - - -class TestTemporalCreatedAtThreading: - async def test_created_at_threaded_to_all_contexts(self) -> None: - fixed = datetime(2026, 6, 22, 12, 0, 0, tzinfo=timezone.utc) - _, fake_streaming = await _run_activity(_tool_then_text_envelopes(), created_at=fixed) - assert len(fake_streaming.created_ats) == len(fake_streaming.messages_opened) - assert all(ts == fixed for ts in fake_streaming.created_ats), ( - f"Expected every context stamped with {fixed}, got {fake_streaming.created_ats}" - ) - - async def test_default_created_at_is_none(self) -> None: - _, fake_streaming = await _run_activity(_tool_then_text_envelopes(), created_at=None) - assert all(ts is None for ts in fake_streaming.created_ats) - - async def test_created_at_deterministic_across_runs(self) -> None: - fixed = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc) - _, first = await _run_activity(_tool_then_text_envelopes(), created_at=fixed) - _, second = await _run_activity(_tool_then_text_envelopes(), created_at=fixed) - assert first.created_ats == second.created_ats diff --git a/tests/lib/core/harness/test_harness_codex_async.py b/tests/lib/core/harness/test_harness_codex_async.py deleted file mode 100644 index c31ebfa49..000000000 --- a/tests/lib/core/harness/test_harness_codex_async.py +++ /dev/null @@ -1,228 +0,0 @@ -"""Integration test: async (Redis-streaming) channel with a codex turn. - -Exercises the unified harness surface (UnifiedEmitter.auto_send_turn + CodexTurn) -with hand-built codex ``exec --json`` event dicts and a fake streaming backend -so the test runs fully offline (no codex CLI subprocess, no Redis, no Agentex -server). - -Native event shapes are copied verbatim from the codex turn test / conformance -fixtures (command_execution -> tool round-trip; agent_message -> text; -turn.completed -> usage). - -What is tested --------------- -- auto_send pushes the correct message contexts: tool_request + tool_response - + text (in that order). -- TurnResult.final_text equals the final agent_message text. -- TurnResult.usage reflects the codex ``turn.completed`` usage (input/output/ - total tokens) plus the locally-counted num_tool_calls. -- With a SpanTracer + fake tracing, a tool span is derived on the async path. - -What is NOT covered without live infrastructure ------------------------------------------------ -- Actual Redis streaming. -- The ACP on_task_event_send / on_task_create / on_task_cancel lifecycle. -- A real codex CLI subprocess / live model behaviour. - -See also: test_harness_codex_sync.py and test_harness_codex_temporal.py. -""" - -from __future__ import annotations - -from typing import Any, AsyncIterator - -from agentex.types.task_message import TaskMessage -from agentex.lib.core.harness.types import TurnResult -from agentex.lib.core.harness.tracer import SpanTracer -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.lib.adk._modules._codex_turn import CodexTurn - -from ._fakes import FakeTracing - -# --------------------------------------------------------------------------- -# Native codex event fixtures -# --------------------------------------------------------------------------- - - -def _tool_then_text_events() -> list[dict[str, Any]]: - return [ - {"type": "thread.started", "thread_id": "thread-abc"}, - { - "type": "item.started", - "item": {"id": "tool1", "type": "command_execution", "command": "cat weather.txt"}, - }, - { - "type": "item.completed", - "item": { - "id": "tool1", - "type": "command_execution", - "command": "cat weather.txt", - "aggregated_output": "sunny and 72F", - "exit_code": 0, - }, - }, - {"type": "item.started", "item": {"id": "msg1", "type": "agent_message", "text": ""}}, - { - "type": "item.completed", - "item": {"id": "msg1", "type": "agent_message", "text": "The weather is sunny and 72F."}, - }, - { - "type": "turn.completed", - "usage": {"input_tokens": 20, "output_tokens": 8, "total_tokens": 28}, - }, - ] - - -async def _aiter(events: list[dict[str, Any]]) -> AsyncIterator[dict[str, Any]]: - for e in events: - yield e - - -# --------------------------------------------------------------------------- -# Fake streaming backend -# --------------------------------------------------------------------------- - - -class _FakeCtx: - def __init__(self, sink: list[Any], ctype: str, initial_content: Any) -> None: - self.sink = sink - self.ctype = ctype - self.task_message = TaskMessage(id="msg-1", task_id="task1", content=initial_content) - - async def __aenter__(self) -> "_FakeCtx": - self.sink.append(("open", self.ctype, self.task_message.content)) - return self - - async def __aexit__(self, *args: Any) -> bool: - await self.close() - return False - - async def close(self) -> None: - self.sink.append(("close", self.ctype)) - - async def stream_update(self, update: Any) -> Any: - self.sink.append(("delta", self.ctype, update)) - return update - - -class _FakeStreaming: - def __init__(self) -> None: - self.sink: list[Any] = [] - self.messages_opened: list[Any] = [] - - def streaming_task_message_context( - self, - task_id: str, - initial_content: Any, - streaming_mode: str = "coalesced", - created_at: Any = None, - ) -> _FakeCtx: - ctype = getattr(initial_content, "type", None) or "" - self.messages_opened.append(initial_content) - return _FakeCtx(self.sink, ctype, initial_content) - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -async def _run_auto_send_turn( - events: list[dict[str, Any]], - trace_id: str | None = None, - parent_span_id: str | None = None, - fake_tracing: FakeTracing | None = None, -) -> tuple[TurnResult, _FakeStreaming]: - fake_streaming = _FakeStreaming() - tracer: SpanTracer | bool | None = None - if trace_id and fake_tracing is not None: - tracer = SpanTracer( - trace_id=trace_id, - parent_span_id=parent_span_id, - task_id="task1", - tracing=fake_tracing, - ) - - turn = CodexTurn(_aiter(events), model="o4-mini") - emitter = UnifiedEmitter( - task_id="task1", - trace_id=trace_id, - parent_span_id=parent_span_id, - tracer=tracer if tracer is not None else False, - streaming=fake_streaming, - ) - result = await emitter.auto_send_turn(turn) - return result, fake_streaming - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -class TestAsyncAutoSendMessageOrder: - async def test_tool_request_pushed_before_tool_response(self) -> None: - _, fake_streaming = await _run_auto_send_turn(_tool_then_text_events()) - types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] - assert "tool_request" in types - assert "tool_response" in types - assert types.index("tool_request") < types.index("tool_response") - - async def test_text_pushed_last(self) -> None: - _, fake_streaming = await _run_auto_send_turn(_tool_then_text_events()) - types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] - assert types[-1] == "text", f"Expected last type=text, got {types}" - - -class TestAsyncAutoSendContent: - async def test_tool_response_content(self) -> None: - _, fake_streaming = await _run_auto_send_turn(_tool_then_text_events()) - tool_resps = [m for m in fake_streaming.messages_opened if isinstance(m, ToolResponseContent)] - assert len(tool_resps) == 1 - assert "72F" in str(tool_resps[0].content) - - async def test_tool_call_ids_match(self) -> None: - _, fake_streaming = await _run_auto_send_turn(_tool_then_text_events()) - tool_req = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolRequestContent)) - tool_resp = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolResponseContent)) - assert tool_req.tool_call_id == tool_resp.tool_call_id - - -class TestAsyncAutoSendFinalTextAndUsage: - async def test_final_text_matches_last_text(self) -> None: - result, _ = await _run_auto_send_turn(_tool_then_text_events()) - assert result.final_text == "The weather is sunny and 72F." - - async def test_usage_from_turn_completed(self) -> None: - """TurnResult.usage reflects the codex turn.completed usage + tool count.""" - result, _ = await _run_auto_send_turn(_tool_then_text_events()) - assert result.usage is not None - assert result.usage.input_tokens == 20 - assert result.usage.output_tokens == 8 - assert result.usage.total_tokens == 28 - assert result.usage.model == "o4-mini" - assert result.usage.num_tool_calls == 1 - assert result.usage.num_llm_calls == 1 - - async def test_context_lifecycle_open_then_close(self) -> None: - _, fake_streaming = await _run_auto_send_turn(_tool_then_text_events()) - opens = [e for e in fake_streaming.sink if e[0] == "open"] - closes = [e for e in fake_streaming.sink if e[0] == "close"] - assert len(opens) == len(closes) - assert len(opens) == len(fake_streaming.messages_opened) - - -class TestAsyncAutoSendSpanDerivation: - async def test_tool_span_derived_on_async_path(self) -> None: - fake_tracing = FakeTracing() - await _run_auto_send_turn( - _tool_then_text_events(), - trace_id="trace1", - parent_span_id="parent", - fake_tracing=fake_tracing, - ) - assert len(fake_tracing.started) == 1 - assert len(fake_tracing.ended) == 1 - assert "72F" in str(fake_tracing.ended[0][1]) diff --git a/tests/lib/core/harness/test_harness_codex_sync.py b/tests/lib/core/harness/test_harness_codex_sync.py deleted file mode 100644 index 6129716ee..000000000 --- a/tests/lib/core/harness/test_harness_codex_sync.py +++ /dev/null @@ -1,276 +0,0 @@ -"""Integration test: sync (HTTP-yield) channel with a codex turn. - -Exercises the unified harness surface (UnifiedEmitter.yield_turn + CodexTurn) -with hand-built codex ``exec --json`` event dicts so the test runs fully offline -(no codex CLI subprocess, no API keys, no Agentex server). - -Native stream shapes ---------------------- -``CodexTurn`` consumes an async iterator of raw codex events (str | dict). The -event shapes used here are copied verbatim from the codex turn test -(tests/lib/adk/test_codex_turn.py) and the codex conformance fixtures -(tests/lib/core/harness/conformance/test_codex_conformance.py): - - command_execution item -> Start(ToolRequestContent) + Done + Full(ToolResponseContent) - agent_message item -> Start(TextContent) + ... + Full/Done - reasoning item -> Start(ReasoningContent) + Full(ReasoningContent) - turn.completed -> usage - -Reasoning note --------------- -The codex converter emits reasoning as Start(ReasoningContent) + deltas + Done. -The SpanDeriver opens a reasoning span on Start and closes it normally when the -Done event is observed (is_complete=True). - -What is tested --------------- -- The sync handler forwards StreamTaskMessage* events in canonical order: - tool_request (Start+Done) -> tool_response (Full) -> text. -- The tool_response carries the command output, keyed by item id. -- With a trace_id + fake tracing, a tool span is opened on Done(tool_request) - and closed on the matching Full(tool_response), and a reasoning span is - opened and closed normally for a reasoning item. - -What is NOT covered without live infrastructure ------------------------------------------------ -- Actual HTTP streaming over the ACP sync endpoint. -- A real codex CLI subprocess / live model behaviour. -- The full FastACP request/response lifecycle. - -See also: test_harness_codex_async.py and test_harness_codex_temporal.py. -""" - -from __future__ import annotations - -from typing import Any, AsyncIterator, override - -from agentex.lib.core.harness.types import OpenSpan, CloseSpan -from agentex.lib.core.harness.tracer import SpanTracer -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.types.task_message_update import ( - StreamTaskMessageFull, - StreamTaskMessageStart, -) -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.lib.adk._modules._codex_turn import CodexTurn - -from ._fakes import FakeTracing - -# --------------------------------------------------------------------------- -# Native codex event fixtures (copied from the turn + conformance tests) -# --------------------------------------------------------------------------- - - -def _tool_then_text_events() -> list[dict[str, Any]]: - """A command_execution tool round-trip followed by a final text reply.""" - return [ - {"type": "thread.started", "thread_id": "thread-abc"}, - {"type": "turn.started"}, - { - "type": "item.started", - "item": {"id": "tool1", "type": "command_execution", "command": "cat weather.txt"}, - }, - { - "type": "item.completed", - "item": { - "id": "tool1", - "type": "command_execution", - "command": "cat weather.txt", - "aggregated_output": "sunny and 72F", - "exit_code": 0, - }, - }, - {"type": "item.started", "item": {"id": "msg1", "type": "agent_message", "text": ""}}, - { - "type": "item.completed", - "item": {"id": "msg1", "type": "agent_message", "text": "The weather is sunny and 72F."}, - }, - { - "type": "turn.completed", - "usage": {"input_tokens": 20, "output_tokens": 8, "total_tokens": 28}, - }, - ] - - -def _reasoning_events() -> list[dict[str, Any]]: - return [ - {"type": "thread.started", "thread_id": "thread-reason"}, - {"type": "item.started", "item": {"id": "r1", "type": "reasoning", "text": ""}}, - { - "type": "item.completed", - "item": {"id": "r1", "type": "reasoning", "text": "Step 1: analyze\nStep 2: solve"}, - }, - {"type": "item.started", "item": {"id": "msg2", "type": "agent_message", "text": ""}}, - {"type": "item.completed", "item": {"id": "msg2", "type": "agent_message", "text": "42"}}, - {"type": "turn.completed", "usage": {"input_tokens": 30, "output_tokens": 20, "total_tokens": 50}}, - ] - - -async def _aiter(events: list[dict[str, Any]]) -> AsyncIterator[dict[str, Any]]: - for e in events: - yield e - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -async def _run_yield_turn( - events: list[dict[str, Any]], - trace_id: str | None = None, - parent_span_id: str | None = None, - fake_tracing: FakeTracing | None = None, -) -> list[Any]: - tracer: SpanTracer | bool | None = None - if trace_id and fake_tracing is not None: - tracer = SpanTracer( - trace_id=trace_id, - parent_span_id=parent_span_id, - task_id="task1", - tracing=fake_tracing, - ) - - turn = CodexTurn(_aiter(events), model="o4-mini") - emitter = UnifiedEmitter( - task_id="task1", - trace_id=trace_id, - parent_span_id=parent_span_id, - tracer=tracer if tracer is not None else False, - ) - return [ev async for ev in emitter.yield_turn(turn)] - - -# --------------------------------------------------------------------------- -# Tests: event order and content -# --------------------------------------------------------------------------- - - -class TestSyncYieldEventOrder: - async def test_tool_request_precedes_tool_response(self) -> None: - events = await _run_yield_turn(_tool_then_text_events()) - content_types = [ - getattr(getattr(ev, "content", None), "type", None) - for ev in events - if isinstance(ev, (StreamTaskMessageStart, StreamTaskMessageFull)) - ] - assert "tool_request" in content_types - assert "tool_response" in content_types - assert content_types.index("tool_request") < content_types.index("tool_response") - - async def test_text_appears_after_tool_response(self) -> None: - events = await _run_yield_turn(_tool_then_text_events()) - tool_resp_pos = next( - i - for i, ev in enumerate(events) - if isinstance(ev, StreamTaskMessageFull) - and getattr(getattr(ev, "content", None), "type", None) == "tool_response" - ) - text_start_pos = next( - i - for i, ev in enumerate(events) - if isinstance(ev, StreamTaskMessageStart) and getattr(getattr(ev, "content", None), "type", None) == "text" - ) - assert tool_resp_pos < text_start_pos - - async def test_tool_response_carries_command_output(self) -> None: - events = await _run_yield_turn(_tool_then_text_events()) - full_responses = [ - ev.content - for ev in events - if isinstance(ev, StreamTaskMessageFull) and isinstance(getattr(ev, "content", None), ToolResponseContent) - ] - assert len(full_responses) == 1 - tool_response = full_responses[0] - assert isinstance(tool_response, ToolResponseContent) - assert "72F" in str(tool_response.content) - - async def test_tool_request_present(self) -> None: - events = await _run_yield_turn(_tool_then_text_events()) - tool_reqs = [ - ev.content for ev in events if isinstance(getattr(ev, "content", None), ToolRequestContent) - ] - assert len(tool_reqs) == 1 - - -# --------------------------------------------------------------------------- -# Tests: span derivation on the yield path -# --------------------------------------------------------------------------- - - -class TestSyncYieldSpanDerivation: - async def test_tool_span_opened_and_closed(self) -> None: - """Done(tool_request) opens a tool span; Full(tool_response) closes it.""" - fake_tracing = FakeTracing() - await _run_yield_turn( - _tool_then_text_events(), - trace_id="trace1", - parent_span_id="parent-span", - fake_tracing=fake_tracing, - ) - assert len(fake_tracing.started) == 1 - assert len(fake_tracing.ended) == 1 - _name, parent_id, _input = fake_tracing.started[0] - assert parent_id == "parent-span" - - async def test_tool_span_output_is_command_output(self) -> None: - fake_tracing = FakeTracing() - await _run_yield_turn( - _tool_then_text_events(), - trace_id="trace1", - parent_span_id="parent-span", - fake_tracing=fake_tracing, - ) - _name, output = fake_tracing.ended[0] - assert "72F" in str(output) - - async def test_reasoning_span_opened_then_done_closed(self) -> None: - """A codex reasoning item emits Start+Delta+Done: the reasoning span - opens and is closed normally when the Done event is observed.""" - received_signals: list[Any] = [] - - class _RecordingTracer(SpanTracer): - @override - async def handle(self, signal: Any) -> None: - received_signals.append(signal) - await super().handle(signal) - - fake_tracing = FakeTracing() - tracer = _RecordingTracer( - trace_id="trace1", - parent_span_id="parent-span", - task_id="task1", - tracing=fake_tracing, - ) - turn = CodexTurn(_aiter(_reasoning_events()), model="o4-mini") - emitter = UnifiedEmitter(task_id="task1", trace_id="trace1", parent_span_id="parent-span", tracer=tracer) - [_ async for _ in emitter.yield_turn(turn)] - - opens = [s for s in received_signals if isinstance(s, OpenSpan) and s.kind == "reasoning"] - closes = [s for s in received_signals if isinstance(s, CloseSpan) and str(s.key).startswith("reasoning:")] - assert len(opens) == 1, "Reasoning Start must open exactly one reasoning span" - assert len(closes) == 1, "Reasoning span must close exactly once" - assert closes[0].is_complete is True, "Done event closes the reasoning span as complete" - - async def test_no_trace_id_means_no_spans(self) -> None: - fake_tracing = FakeTracing() - turn = CodexTurn(_aiter(_tool_then_text_events()), model="o4-mini") - emitter = UnifiedEmitter(task_id="task1", trace_id=None, parent_span_id=None, tracing=fake_tracing) - [_ async for _ in emitter.yield_turn(turn)] - assert fake_tracing.started == [] - assert fake_tracing.ended == [] - - async def test_tracer_false_suppresses_spans(self) -> None: - fake_tracing = FakeTracing() - turn = CodexTurn(_aiter(_tool_then_text_events()), model="o4-mini") - emitter = UnifiedEmitter( - task_id="task1", - trace_id="trace1", - parent_span_id="parent-span", - tracer=False, - tracing=fake_tracing, - ) - [_ async for _ in emitter.yield_turn(turn)] - assert fake_tracing.started == [] - assert fake_tracing.ended == [] diff --git a/tests/lib/core/harness/test_harness_codex_temporal.py b/tests/lib/core/harness/test_harness_codex_temporal.py deleted file mode 100644 index 0af0b862b..000000000 --- a/tests/lib/core/harness/test_harness_codex_temporal.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Integration test: Temporal channel with a codex turn, offline. - -The codex tap is a pure library adapter (subprocess/sandbox provisioning lives -in the golden agent; there is no codex-specific temporal helper like langgraph's -``stream_langgraph_events``). In a Temporal deployment the codex CLI runs inside -a Temporal activity and the resulting canonical stream is delivered via the SAME -``UnifiedEmitter.auto_send_turn`` path used by the non-temporal async channel. -The only temporal-specific concern at the harness boundary is that the activity -stamps messages with a deterministic ``created_at`` (e.g. ``workflow.now()``) -for replay determinism. - -This suite exercises the auto_send path inside an activity-style call plus the -temporal-only contract: ``created_at`` is threaded through to every streaming -context. The native codex event shapes are copied verbatim from the codex turn -test / conformance fixtures. - -What is tested --------------- -- The canonical message sequence (tool_request -> tool_response -> text) is - delivered via auto_send_turn, exactly as inside a Temporal activity. -- ``created_at`` passed to ``auto_send_turn`` is forwarded to every - ``streaming_task_message_context`` call (deterministic timestamping). -- Final text + usage from turn.completed are returned. - -What is NOT covered without live infrastructure ------------------------------------------------ -- Temporal scheduling / durability / replay behaviour. -- Redis streaming (requires a running Redis instance). -- A real codex CLI subprocess / live model behaviour. - -See also: test_harness_codex_sync.py and test_harness_codex_async.py. -""" - -from __future__ import annotations - -from typing import Any, AsyncIterator -from datetime import datetime, timezone - -from agentex.types.task_message import TaskMessage -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.lib.adk._modules._codex_turn import CodexTurn - - -def _tool_then_text_events() -> list[dict[str, Any]]: - return [ - {"type": "thread.started", "thread_id": "thread-abc"}, - { - "type": "item.started", - "item": {"id": "tool1", "type": "command_execution", "command": "cat weather.txt"}, - }, - { - "type": "item.completed", - "item": { - "id": "tool1", - "type": "command_execution", - "command": "cat weather.txt", - "aggregated_output": "sunny and 72F", - "exit_code": 0, - }, - }, - {"type": "item.started", "item": {"id": "msg1", "type": "agent_message", "text": ""}}, - { - "type": "item.completed", - "item": {"id": "msg1", "type": "agent_message", "text": "The weather is sunny and 72F."}, - }, - { - "type": "turn.completed", - "usage": {"input_tokens": 20, "output_tokens": 8, "total_tokens": 28}, - }, - ] - - -async def _aiter(events: list[dict[str, Any]]) -> AsyncIterator[dict[str, Any]]: - for e in events: - yield e - - -# --------------------------------------------------------------------------- -# Fake streaming backend that records created_at -# --------------------------------------------------------------------------- - - -class _FakeCtx: - def __init__(self, ctype: str, initial_content: Any) -> None: - self.ctype = ctype - self.task_message = TaskMessage(id="msg-1", task_id="task1", content=initial_content) - - async def __aenter__(self) -> "_FakeCtx": - return self - - async def __aexit__(self, *args: Any) -> bool: - await self.close() - return False - - async def close(self) -> None: - pass - - async def stream_update(self, update: Any) -> Any: - return update - - -class _FakeStreaming: - def __init__(self) -> None: - self.messages_opened: list[Any] = [] - self.created_ats: list[Any] = [] - - def streaming_task_message_context( - self, - task_id: str, - initial_content: Any, - streaming_mode: str = "coalesced", - created_at: Any = None, - ) -> _FakeCtx: - ctype = getattr(initial_content, "type", None) or "" - self.messages_opened.append(initial_content) - self.created_ats.append(created_at) - return _FakeCtx(ctype, initial_content) - - -async def _run_activity(events: list[dict[str, Any]], created_at: datetime | None) -> tuple[Any, _FakeStreaming]: - fake_streaming = _FakeStreaming() - turn = CodexTurn(_aiter(events), model="o4-mini") - emitter = UnifiedEmitter( - task_id="task1", - trace_id=None, - parent_span_id=None, - tracer=False, - streaming=fake_streaming, - ) - result = await emitter.auto_send_turn(turn, created_at=created_at) - return result, fake_streaming - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -class TestTemporalActivityDelivery: - async def test_canonical_sequence_delivered(self) -> None: - _, fake_streaming = await _run_activity(_tool_then_text_events(), created_at=None) - types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] - assert "tool_request" in types - assert "tool_response" in types - assert types.index("tool_request") < types.index("tool_response") - assert types[-1] == "text" - - async def test_tool_round_trip_keyed_correctly(self) -> None: - _, fake_streaming = await _run_activity(_tool_then_text_events(), created_at=None) - tool_req = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolRequestContent)) - tool_resp = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolResponseContent)) - assert tool_req.tool_call_id == tool_resp.tool_call_id - - async def test_final_text_and_usage(self) -> None: - result, _ = await _run_activity(_tool_then_text_events(), created_at=None) - assert result.final_text == "The weather is sunny and 72F." - assert result.usage.total_tokens == 28 - assert result.usage.num_tool_calls == 1 - - -class TestTemporalCreatedAtThreading: - async def test_created_at_threaded_to_all_contexts(self) -> None: - fixed = datetime(2026, 6, 22, 12, 0, 0, tzinfo=timezone.utc) - _, fake_streaming = await _run_activity(_tool_then_text_events(), created_at=fixed) - assert len(fake_streaming.created_ats) == len(fake_streaming.messages_opened) - assert all(ts == fixed for ts in fake_streaming.created_ats), ( - f"Expected every context stamped with {fixed}, got {fake_streaming.created_ats}" - ) - - async def test_default_created_at_is_none(self) -> None: - _, fake_streaming = await _run_activity(_tool_then_text_events(), created_at=None) - assert all(ts is None for ts in fake_streaming.created_ats) - - async def test_created_at_deterministic_across_runs(self) -> None: - fixed = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc) - _, first = await _run_activity(_tool_then_text_events(), created_at=fixed) - _, second = await _run_activity(_tool_then_text_events(), created_at=fixed) - assert first.created_ats == second.created_ats diff --git a/tests/lib/core/harness/test_harness_langgraph_async.py b/tests/lib/core/harness/test_harness_langgraph_async.py deleted file mode 100644 index 09e92102b..000000000 --- a/tests/lib/core/harness/test_harness_langgraph_async.py +++ /dev/null @@ -1,276 +0,0 @@ -"""Integration test: async (Redis-streaming) channel with a LangGraph agent. - -Exercises the unified harness surface (UnifiedEmitter.auto_send_turn + LangGraphTurn) -with a minimal fake LangGraph stream so the test runs fully offline (no API -keys, no Redis, no Agentex server). - -Agent description ------------------ -A simulated single-tool agent run using hand-crafted LangGraph event tuples: -one tool request + response, followed by a final text reply. - -What is tested --------------- -- The async handler pushes the correct sequence of messages to the fake streaming - backend: Full(ToolRequest) + Full(ToolResponse) + text Start/Delta/Done. -- final_text accumulates all text (not just last segment โ€” unified behavior). -- Tool messages go through streaming_task_message_context (not messages.create). -- With a SpanTracer, Full tool events produce tool spans (request opens, response - closes), aligning LangGraph tracing with the Start+Done harnesses. - -What is NOT covered without live infrastructure ------------------------------------------------ -- Actual Redis streaming (requires a running Redis instance). -- The ACP on_task_event_send / on_task_create / on_task_cancel lifecycle. -- Real LLM calls or real LangGraph graph execution. -- The full FastACP async request lifecycle. - -See also: test_harness_langgraph_sync.py and test_harness_langgraph_temporal.py -for the other two channels. -""" - -from __future__ import annotations - -import sys -from typing import Any -from dataclasses import field, dataclass - -import pytest - -from agentex.types.task_message import TaskMessage -from agentex.types.text_content import TextContent -from agentex.lib.core.harness.types import TurnResult -from agentex.lib.core.harness.tracer import SpanTracer -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.lib.adk._modules._langgraph_turn import LangGraphTurn - -from ._fakes import FakeTracing - -# --------------------------------------------------------------------------- -# Remove conftest stubs so real langchain_core types are used -# --------------------------------------------------------------------------- - - -@pytest.fixture(autouse=True) -def _real_langchain_core(): - stub_keys = [k for k in sys.modules if k.startswith("langchain_core") or k.startswith("langgraph")] - saved = {k: sys.modules.pop(k) for k in stub_keys} - import importlib - - importlib.import_module("langchain_core.messages") - yield - sys.modules.update(saved) - - -# --------------------------------------------------------------------------- -# Fake streaming backend (replaces adk.streaming; no Redis required) -# --------------------------------------------------------------------------- - - -@dataclass -class _FakeCtx: - ctype: str - initial_content: Any - task_message: TaskMessage - closed: bool = False - deltas: list[Any] = field(default_factory=list) - - async def __aenter__(self) -> "_FakeCtx": - return self - - async def __aexit__(self, *args: Any) -> bool: - await self.close() - return False - - async def close(self) -> None: - self.closed = True - - async def stream_update(self, update: Any) -> Any: - self.deltas.append(update) - return update - - -class _FakeStreaming: - def __init__(self) -> None: - self.contexts: list[_FakeCtx] = [] - - def streaming_task_message_context(self, task_id: str, initial_content: Any, **kw: Any) -> _FakeCtx: - ctype = getattr(initial_content, "type", None) or "" - tm = TaskMessage(id=f"m{len(self.contexts) + 1}", task_id=task_id, content=initial_content) - ctx = _FakeCtx(ctype=ctype, initial_content=initial_content, task_message=tm) - self.contexts.append(ctx) - return ctx - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_stream(events: list[tuple[str, Any]]): - async def _gen(): - for e in events: - yield e - - return _gen() - - -async def _run_auto_send_turn( - stream_events: list[tuple[str, Any]], - trace_id: str | None = None, -) -> tuple[TurnResult, _FakeStreaming, FakeTracing | None]: - fake_streaming = _FakeStreaming() - fake_tracing = FakeTracing() if trace_id else None - - tracer: SpanTracer | bool = False - if trace_id and fake_tracing is not None: - tracer = SpanTracer(trace_id=trace_id, parent_span_id=None, task_id="task1", tracing=fake_tracing) - - turn = LangGraphTurn(_make_stream(stream_events), model=None) - emitter = UnifiedEmitter( - task_id="task1", - trace_id=trace_id, - parent_span_id=None, - tracer=tracer, - streaming=fake_streaming, - ) - result = await emitter.auto_send_turn(turn) - return result, fake_streaming, fake_tracing - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -class TestAsyncAutoSendChannel: - async def test_text_only_streams_text_and_returns_final(self): - from langchain_core.messages import AIMessage, AIMessageChunk - - chunk = AIMessageChunk(content="Hello from LangGraph!") - ai_msg = AIMessage(content="Hello from LangGraph!") - events = [ - ("messages", (chunk, {})), - ("updates", {"agent": {"messages": [ai_msg]}}), - ] - result, fake_streaming, _ = await _run_auto_send_turn(events) - - assert result.final_text == "Hello from LangGraph!" - text_ctxs = [c for c in fake_streaming.contexts if c.ctype == "text"] - assert len(text_ctxs) == 1 - assert text_ctxs[0].closed is True - - async def test_tool_call_posted_via_streaming_context(self): - from langchain_core.messages import AIMessage - - tc = {"id": "call_1", "name": "get_weather", "args": {"city": "Paris"}} - ai_msg = AIMessage(content="", tool_calls=[tc]) - events = [("updates", {"agent": {"messages": [ai_msg]}})] - - result, fake_streaming, _ = await _run_auto_send_turn(events) - - # Tool request via streaming_task_message_context (Full event) - tool_req_ctxs = [c for c in fake_streaming.contexts if isinstance(c.initial_content, ToolRequestContent)] - assert len(tool_req_ctxs) == 1 - assert tool_req_ctxs[0].initial_content.tool_call_id == "call_1" - assert tool_req_ctxs[0].closed is True - assert tool_req_ctxs[0].deltas == [], "Full messages have no deltas" - - async def test_tool_response_posted_via_streaming_context(self): - from langchain_core.messages import ToolMessage - - tool_msg = ToolMessage(content="Sunny, 72F", tool_call_id="call_1", name="get_weather") - events = [("updates", {"tools": {"messages": [tool_msg]}})] - - _, fake_streaming, _ = await _run_auto_send_turn(events) - - tool_resp_ctxs = [c for c in fake_streaming.contexts if isinstance(c.initial_content, ToolResponseContent)] - assert len(tool_resp_ctxs) == 1 - assert tool_resp_ctxs[0].initial_content.content == "Sunny, 72F" - assert tool_resp_ctxs[0].closed is True - - async def test_multi_step_final_text_is_last_segment(self): - """Unified surface: final_text uses last-segment semantics. - - auto_send resets final_text_parts when a new Start(TextContent) is seen, - so multi-step turns (text -> tool -> text) return only the LAST text segment. - This matches the behaviour documented in auto_send.py and mirrors - stream_pydantic_ai_events. - """ - from langchain_core.messages import AIMessage, ToolMessage, AIMessageChunk - - chunk1 = AIMessageChunk(content="Searching...") - ai_msg1 = AIMessage(content="Searching...", tool_calls=[{"id": "c1", "name": "s", "args": {}}]) - tool_msg = ToolMessage(content="results", tool_call_id="c1", name="s") - chunk2 = AIMessageChunk(content="Found it!") - ai_msg2 = AIMessage(content="Found it!") - - events = [ - ("messages", (chunk1, {})), - ("updates", {"agent": {"messages": [ai_msg1]}}), - ("updates", {"tools": {"messages": [tool_msg]}}), - ("messages", (chunk2, {})), - ("updates", {"agent": {"messages": [ai_msg2]}}), - ] - result, fake_streaming, _ = await _run_auto_send_turn(events) - - # Last segment only โ€” first text segment is NOT in final_text - assert result.final_text == "Found it!" - - # Two text streaming contexts still opened (both streamed to Redis) - text_ctxs = [c for c in fake_streaming.contexts if isinstance(c.initial_content, TextContent)] - assert len(text_ctxs) == 2 - - async def test_empty_stream_returns_empty_final_text(self): - result, fake_streaming, _ = await _run_auto_send_turn([]) - assert result.final_text == "" - assert fake_streaming.contexts == [] - - async def test_turn_usage_populated_after_events_consumed(self): - """LangGraphTurn.usage() is populated via the on_final_ai_message callback - during event iteration. TurnResult.usage is a snapshot from before events run - (emitter.auto_send_turn evaluates turn.usage() eagerly); the authoritative - post-iteration usage is on turn.usage() directly.""" - from langchain_core.messages import AIMessage - - fake_streaming = _FakeStreaming() - usage_meta = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15} - ai_msg = AIMessage(content="hi", usage_metadata=usage_meta) - events = [("updates", {"agent": {"messages": [ai_msg]}})] - - turn = LangGraphTurn(_make_stream(events), model="gpt-4") - emitter = UnifiedEmitter( - task_id="task1", trace_id=None, parent_span_id=None, tracer=False, streaming=fake_streaming - ) - await emitter.auto_send_turn(turn) - - # After auto_send_turn, turn.usage() has the captured values - usage = turn.usage() - assert usage.input_tokens == 10 - assert usage.output_tokens == 5 - assert usage.total_tokens == 15 - - async def test_tracer_produces_tool_spans_for_full_events(self): - """SpanDeriver handles Full tool events (request opens, response closes). - - Full(ToolRequestContent) opens a tool span; Full(ToolResponseContent) closes it. - This aligns LangGraph tracing with Start+Done harnesses (pydantic-ai, openai-agents). - """ - from langchain_core.messages import AIMessage, ToolMessage - - tc = {"id": "c1", "name": "t", "args": {}} - ai_msg = AIMessage(content="", tool_calls=[tc]) - tool_msg = ToolMessage(content="ok", tool_call_id="c1", name="t") - - events = [ - ("updates", {"agent": {"messages": [ai_msg]}}), - ("updates", {"tools": {"messages": [tool_msg]}}), - ] - _, _, fake_tracing = await _run_auto_send_turn(events, trace_id="trace-1") - - assert fake_tracing is not None - assert len(fake_tracing.started) == 1, "Full(ToolRequestContent) opens one tool span" - assert fake_tracing.started[0][0] == "t", "span name matches the tool name" - assert len(fake_tracing.ended) == 1, "Full(ToolResponseContent) closes the span" diff --git a/tests/lib/core/harness/test_harness_langgraph_sync.py b/tests/lib/core/harness/test_harness_langgraph_sync.py deleted file mode 100644 index 67d213b6a..000000000 --- a/tests/lib/core/harness/test_harness_langgraph_sync.py +++ /dev/null @@ -1,205 +0,0 @@ -"""Integration test: sync (HTTP-yield) channel with a LangGraph agent. - -Exercises the unified harness surface (UnifiedEmitter.yield_turn + LangGraphTurn) -with a minimal fake LangGraph stream so the test runs fully offline (no API -keys, no Redis, no Agentex server). - -Agent description ------------------ -A simulated single-tool agent run using hand-crafted LangGraph event tuples: -one tool request + response, followed by a final text reply. - -What is tested --------------- -- The sync handler correctly yields StreamTaskMessage* events in order: - Full(ToolRequest) then Full(ToolResponse) then text Start+Delta+Done. -- With trace_id + fake tracing, the SpanDeriver fires for text events. -- LangGraph emits tool calls as Full events (not Start+Done); the SpanDeriver - opens a tool span on Full(ToolRequestContent) and closes it on the matching - Full(ToolResponseContent) (see test_tracer_produces_tool_spans_for_full_events). -- Final text is accumulated via yield mode. - -What is NOT covered without live infrastructure ------------------------------------------------ -- Actual HTTP streaming over the ACP sync endpoint. -- Real LLM calls or real LangGraph graph execution. -- The full FastACP request/response lifecycle. - -See also: test_harness_langgraph_async.py and test_harness_langgraph_temporal.py -for the other two channels. -""" - -from __future__ import annotations - -import sys -from typing import Any - -import pytest - -from agentex.lib.core.harness.tracer import SpanTracer -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.types.task_message_update import ( - StreamTaskMessageFull, - StreamTaskMessageStart, -) -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.lib.adk._modules._langgraph_turn import LangGraphTurn - -from ._fakes import FakeTracing - -# --------------------------------------------------------------------------- -# Remove conftest stubs so real langchain_core types are used -# --------------------------------------------------------------------------- - - -@pytest.fixture(autouse=True) -def _real_langchain_core(): - stub_keys = [k for k in sys.modules if k.startswith("langchain_core") or k.startswith("langgraph")] - saved = {k: sys.modules.pop(k) for k in stub_keys} - import importlib - - importlib.import_module("langchain_core.messages") - yield - sys.modules.update(saved) - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_stream(events: list[tuple[str, Any]]): - async def _gen(): - for e in events: - yield e - - return _gen() - - -async def _run_yield_turn( - stream_events: list[tuple[str, Any]], trace_id: str | None = None -) -> tuple[list[Any], FakeTracing | None]: - fake_tracing = FakeTracing() if trace_id else None - tracer: SpanTracer | bool | None = None - if trace_id and fake_tracing is not None: - tracer = SpanTracer(trace_id=trace_id, parent_span_id=None, task_id="task1", tracing=fake_tracing) - - emitter = UnifiedEmitter( - task_id="task1", - trace_id=trace_id, - parent_span_id=None, - tracer=tracer if tracer is not None else False, - ) - turn = LangGraphTurn(_make_stream(stream_events), model=None) - out = [e async for e in emitter.yield_turn(turn)] - return out, fake_tracing - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -class TestSyncYieldChannel: - async def test_text_only_stream_yields_start_delta_done(self): - from langchain_core.messages import AIMessage, AIMessageChunk - - chunk = AIMessageChunk(content="Hello from LangGraph!") - ai_msg = AIMessage(content="Hello from LangGraph!") - events = [ - ("messages", (chunk, {})), - ("updates", {"agent": {"messages": [ai_msg]}}), - ] - out, _ = await _run_yield_turn(events) - - types = [type(e).__name__ for e in out] - assert "StreamTaskMessageStart" in types - assert "StreamTaskMessageDelta" in types - assert "StreamTaskMessageDone" in types - - async def test_tool_call_yields_full_events(self): - from langchain_core.messages import AIMessage, ToolMessage - - tc = {"id": "call_1", "name": "get_weather", "args": {"city": "Paris"}} - ai_msg = AIMessage(content="", tool_calls=[tc]) - tool_msg = ToolMessage(content="Sunny, 72F", tool_call_id="call_1", name="get_weather") - events = [ - ("updates", {"agent": {"messages": [ai_msg]}}), - ("updates", {"tools": {"messages": [tool_msg]}}), - ] - out, _ = await _run_yield_turn(events) - - full_events = [e for e in out if isinstance(e, StreamTaskMessageFull)] - assert len(full_events) == 2 - - contents = [e.content for e in full_events] - assert any(isinstance(c, ToolRequestContent) for c in contents) - assert any(isinstance(c, ToolResponseContent) for c in contents) - - async def test_multi_step_yields_events_in_order(self): - from langchain_core.messages import AIMessage, ToolMessage, AIMessageChunk - - chunk1 = AIMessageChunk(content="Searching...") - ai_msg1 = AIMessage(content="Searching...", tool_calls=[{"id": "c1", "name": "search", "args": {"q": "test"}}]) - tool_msg = ToolMessage(content="results", tool_call_id="c1", name="search") - chunk2 = AIMessageChunk(content="Found it!") - ai_msg2 = AIMessage(content="Found it!") - - events = [ - ("messages", (chunk1, {})), - ("updates", {"agent": {"messages": [ai_msg1]}}), - ("updates", {"tools": {"messages": [tool_msg]}}), - ("messages", (chunk2, {})), - ("updates", {"agent": {"messages": [ai_msg2]}}), - ] - out, _ = await _run_yield_turn(events) - - # Should have multiple start events (one per text segment) - starts = [e for e in out if isinstance(e, StreamTaskMessageStart)] - assert len(starts) >= 2 - # And two Full events (tool req + tool resp) - fulls = [e for e in out if isinstance(e, StreamTaskMessageFull)] - assert len(fulls) == 2 - - async def test_empty_stream_yields_nothing(self): - out, _ = await _run_yield_turn([]) - assert out == [] - - async def test_tracer_produces_tool_spans_for_full_events(self): - """SpanDeriver handles Full tool events (request opens, response closes). - - Full(ToolRequestContent) opens a tool span; Full(ToolResponseContent) closes it. - This aligns LangGraph tracing with Start+Done harnesses (pydantic-ai, openai-agents). - """ - from langchain_core.messages import AIMessage, ToolMessage - - tc = {"id": "c1", "name": "t", "args": {}} - ai_msg = AIMessage(content="", tool_calls=[tc]) - tool_msg = ToolMessage(content="ok", tool_call_id="c1", name="t") - - events = [ - ("updates", {"agent": {"messages": [ai_msg]}}), - ("updates", {"tools": {"messages": [tool_msg]}}), - ] - _, fake_tracing = await _run_yield_turn(events, trace_id="trace-1") - - assert fake_tracing is not None - assert len(fake_tracing.started) == 1, "Full(ToolRequestContent) opens one tool span" - assert fake_tracing.started[0][0] == "t", "span name matches the tool name" - assert len(fake_tracing.ended) == 1, "Full(ToolResponseContent) closes the span" - - async def test_usage_captured_after_yield(self): - from langchain_core.messages import AIMessage - - usage_meta = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15} - ai_msg = AIMessage(content="Hi!", usage_metadata=usage_meta) - events = [("updates", {"agent": {"messages": [ai_msg]}})] - - turn = LangGraphTurn(_make_stream(events), model="gpt-4") - emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None) - _ = [e async for e in emitter.yield_turn(turn)] - - usage = turn.usage() - assert usage.input_tokens == 10 - assert usage.output_tokens == 5 diff --git a/tests/lib/core/harness/test_harness_langgraph_temporal.py b/tests/lib/core/harness/test_harness_langgraph_temporal.py deleted file mode 100644 index 219e92229..000000000 --- a/tests/lib/core/harness/test_harness_langgraph_temporal.py +++ /dev/null @@ -1,232 +0,0 @@ -"""Integration test: Temporal channel with a LangGraph agent. - -The Temporal LangGraph agent pattern uses ``emit_langgraph_messages`` (now in -``_langgraph_sync.py``) inside a Temporal activity. That helper is not -yet unified onto the harness surface (it has its own Redis-streaming code). - -This test file verifies the LangGraph Temporal agent's streaming behavior using -the same fake streaming infrastructure as test_harness_langgraph_async.py. The -key difference from the non-temporal async path is that in Temporal, each agent -turn runs inside a Temporal activity that has already been handed the task_id -and a pre-wired streaming client โ€” so the ``UnifiedEmitter.auto_send_turn`` -path is identical. The graph activities and workflow scaffolding are not tested -here; that requires a running Temporal cluster. - -What is tested --------------- -- stream_langgraph_events (the public async API used by temporal agent acp.py via - the workflow activity) produces the same result via the unified surface. -- Usage from AIMessage.usage_metadata is captured in TurnResult.usage. -- The auto_send_turn path for a temporal-style call (same as async). - -What is NOT covered without live infrastructure ------------------------------------------------ -- Actual Temporal workflow execution (requires a running Temporal cluster). -- The Temporal activity retry/compensation logic. -- LangGraph checkpoint storage via TemporalCheckpointer. -- emit_langgraph_messages (the Temporal-specific streaming helper). -- Real LLM calls or real LangGraph graph execution. - -See also: test_harness_langgraph_sync.py and test_harness_langgraph_async.py. -""" - -from __future__ import annotations - -import sys -from typing import Any -from dataclasses import field, dataclass - -import pytest - -from agentex.types.task_message import TaskMessage -from agentex.types.text_content import TextContent -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.lib.adk._modules._langgraph_turn import LangGraphTurn, stream_langgraph_events - -# --------------------------------------------------------------------------- -# Remove conftest stubs so real langchain_core types are used -# --------------------------------------------------------------------------- - - -@pytest.fixture(autouse=True) -def _real_langchain_core(): - stub_keys = [k for k in sys.modules if k.startswith("langchain_core") or k.startswith("langgraph")] - saved = {k: sys.modules.pop(k) for k in stub_keys} - import importlib - - importlib.import_module("langchain_core.messages") - yield - sys.modules.update(saved) - - -# --------------------------------------------------------------------------- -# Fake streaming backend -# --------------------------------------------------------------------------- - - -@dataclass -class _FakeCtx: - ctype: str - initial_content: Any - task_message: TaskMessage - closed: bool = False - deltas: list[Any] = field(default_factory=list) - - async def __aenter__(self) -> "_FakeCtx": - return self - - async def __aexit__(self, *args: Any) -> bool: - await self.close() - return False - - async def close(self) -> None: - self.closed = True - - async def stream_update(self, update: Any) -> Any: - self.deltas.append(update) - return update - - -class _FakeStreaming: - def __init__(self) -> None: - self.contexts: list[_FakeCtx] = [] - - def streaming_task_message_context(self, task_id: str, initial_content: Any, **kw: Any) -> _FakeCtx: - ctype = getattr(initial_content, "type", None) or "" - tm = TaskMessage(id=f"m{len(self.contexts) + 1}", task_id=task_id, content=initial_content) - ctx = _FakeCtx(ctype=ctype, initial_content=initial_content, task_message=tm) - self.contexts.append(ctx) - return ctx - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_stream(events: list[tuple[str, Any]]): - async def _gen(): - for e in events: - yield e - - return _gen() - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -class TestTemporalAutoSendChannel: - async def test_stream_langgraph_events_plain_text(self, monkeypatch): - """stream_langgraph_events (used by temporal agents via the acp.py activity) returns - the accumulated final text.""" - from langchain_core.messages import AIMessage, AIMessageChunk - - from agentex.lib import adk as adk_module - - fake_streaming = _FakeStreaming() - monkeypatch.setattr(adk_module, "streaming", fake_streaming) - - chunk = AIMessageChunk(content="Hello Temporal!") - ai_msg = AIMessage(content="Hello Temporal!") - events = [ - ("messages", (chunk, {})), - ("updates", {"agent": {"messages": [ai_msg]}}), - ] - - final = await stream_langgraph_events(_make_stream(events), "task-1") - assert final == "Hello Temporal!" - - async def test_stream_langgraph_events_tool_call(self, monkeypatch): - from langchain_core.messages import AIMessage, ToolMessage - - from agentex.lib import adk as adk_module - - fake_streaming = _FakeStreaming() - monkeypatch.setattr(adk_module, "streaming", fake_streaming) - - tc = {"id": "c1", "name": "search", "args": {"q": "test"}} - ai_msg = AIMessage(content="", tool_calls=[tc]) - tool_msg = ToolMessage(content="results", tool_call_id="c1", name="search") - chunk_final = AIMessage(content="Here are the results.") - - events = [ - ("updates", {"agent": {"messages": [ai_msg]}}), - ("updates", {"tools": {"messages": [tool_msg]}}), - ("updates", {"agent": {"messages": [chunk_final]}}), - ] - - final = await stream_langgraph_events(_make_stream(events), "task-1") - - # Check tool request and response posted to fake streaming - tool_req_ctxs = [c for c in fake_streaming.contexts if isinstance(c.initial_content, ToolRequestContent)] - tool_resp_ctxs = [c for c in fake_streaming.contexts if isinstance(c.initial_content, ToolResponseContent)] - assert len(tool_req_ctxs) == 1 - assert len(tool_resp_ctxs) == 1 - assert tool_req_ctxs[0].initial_content.name == "search" - - async def test_langgraph_turn_auto_send_via_unified_emitter(self): - """Direct UnifiedEmitter.auto_send_turn path used by temporal agent workflow - activities. Uses a fake streaming backend (no Redis).""" - from langchain_core.messages import AIMessage, AIMessageChunk - - fake_streaming = _FakeStreaming() - chunk = AIMessageChunk(content="Temporal answer!") - ai_msg = AIMessage(content="Temporal answer!") - events = [ - ("messages", (chunk, {})), - ("updates", {"agent": {"messages": [ai_msg]}}), - ] - - turn = LangGraphTurn(_make_stream(events), model=None) - emitter = UnifiedEmitter( - task_id="task-1", - trace_id=None, - parent_span_id=None, - streaming=fake_streaming, - ) - result = await emitter.auto_send_turn(turn) - - assert result.final_text == "Temporal answer!" - text_ctxs = [c for c in fake_streaming.contexts if isinstance(c.initial_content, TextContent)] - assert len(text_ctxs) == 1 - - async def test_usage_captured_via_turn_after_events_consumed(self): - """Usage from AIMessage.usage_metadata is captured via the on_final_ai_message - callback during event iteration. The authoritative usage is on turn.usage() - after events are consumed (emitter.auto_send_turn evaluates turn.usage() - eagerly before iteration, so TurnResult.usage is a pre-iteration snapshot).""" - from langchain_core.messages import AIMessage - - fake_streaming = _FakeStreaming() - usage_meta = {"input_tokens": 20, "output_tokens": 10, "total_tokens": 30} - ai_msg = AIMessage(content="answer", usage_metadata=usage_meta) - events = [("updates", {"agent": {"messages": [ai_msg]}})] - - turn = LangGraphTurn(_make_stream(events), model="gpt-4o") - emitter = UnifiedEmitter( - task_id="task-1", - trace_id=None, - parent_span_id=None, - streaming=fake_streaming, - ) - await emitter.auto_send_turn(turn) - - # After auto_send_turn, turn.usage() has the captured values - usage = turn.usage() - assert usage.input_tokens == 20 - assert usage.output_tokens == 10 - assert usage.total_tokens == 30 - - async def test_empty_stream_returns_empty_string(self, monkeypatch): - from agentex.lib import adk as adk_module - - fake_streaming = _FakeStreaming() - monkeypatch.setattr(adk_module, "streaming", fake_streaming) - - final = await stream_langgraph_events(_make_stream([]), "task-1") - assert final == "" - assert fake_streaming.contexts == [] diff --git a/tests/lib/core/harness/test_harness_openai_async.py b/tests/lib/core/harness/test_harness_openai_async.py deleted file mode 100644 index 1329b94b9..000000000 --- a/tests/lib/core/harness/test_harness_openai_async.py +++ /dev/null @@ -1,305 +0,0 @@ -"""Integration test: async (Redis-streaming) channel with an OpenAI-agents turn. - -Exercises the unified harness surface (UnifiedEmitter.auto_send_turn + OpenAITurn) -with hand-built canonical StreamTaskMessage* streams and a fake streaming -backend so the test runs fully offline (no API keys, no Redis, no Agentex -server). - -The canonical event shapes are copied from the OpenAI converter contract -(see tests/lib/core/harness/conformance/test_openai_conformance.py): tool calls -are Full(ToolRequestContent) + Full(ToolResponseContent); text is -Start+Delta+Done. - -What is tested --------------- -- auto_send pushes the correct message contexts to the fake streaming backend: - tool_request + tool_response + text (in that order). -- TurnResult.final_text equals the accumulated text deltas. -- TurnResult carries a TurnUsage; via the OpenAITurn result/converter path the - aggregated token usage (input/output/total + num_llm_calls) is surfaced in - TurnResult.usage. -- With a SpanTracer + fake tracing, a tool span is derived on the async path. - -What is NOT covered without live infrastructure ------------------------------------------------ -- Actual Redis streaming. -- The ACP on_task_event_send / on_task_create / on_task_cancel lifecycle. -- A real Runner.run_streamed execution / live OpenAI model behaviour. - -See also: test_harness_openai_sync.py and test_harness_openai_temporal.py. -""" - -from __future__ import annotations - -from typing import Any - -import pytest -from agents.usage import Usage - -from agentex.types.text_delta import TextDelta -from agentex.types.task_message import TaskMessage -from agentex.types.text_content import TextContent -from agentex.lib.core.harness.types import TurnResult, StreamTaskMessage -from agentex.lib.core.harness.tracer import SpanTracer -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.lib.adk._modules._openai_turn import OpenAITurn - -from ._fakes import FakeTracing - -# --------------------------------------------------------------------------- -# Canonical event fixtures (copied from the OpenAI converter contract) -# --------------------------------------------------------------------------- - - -def _tool_then_text_events() -> list[StreamTaskMessage]: - return [ - StreamTaskMessageFull( - type="full", - index=0, - content=ToolRequestContent( - type="tool_request", - author="agent", - tool_call_id="call_1", - name="get_weather", - arguments={"city": "Paris"}, - ), - ), - StreamTaskMessageFull( - type="full", - index=1, - content=ToolResponseContent( - type="tool_response", - author="agent", - tool_call_id="call_1", - name="get_weather", - content="The weather in Paris is sunny and 72F", - ), - ), - StreamTaskMessageStart( - type="start", - index=2, - content=TextContent(type="text", author="agent", content=""), - ), - StreamTaskMessageDelta(type="delta", index=2, delta=TextDelta(type="text", text_delta="Sunny ")), - StreamTaskMessageDelta(type="delta", index=2, delta=TextDelta(type="text", text_delta="and 72F.")), - StreamTaskMessageDone(type="done", index=2), - ] - - -async def _canonical_stream(events: list[StreamTaskMessage]): - for e in events: - yield e - - -# --------------------------------------------------------------------------- -# Fake streaming backend (replaces adk.streaming; no Redis required) -# --------------------------------------------------------------------------- - - -class _FakeCtx: - def __init__(self, sink: list[Any], ctype: str, initial_content: Any) -> None: - self.sink = sink - self.ctype = ctype - self.task_message = TaskMessage(id="msg-1", task_id="task1", content=initial_content) - - async def __aenter__(self) -> "_FakeCtx": - self.sink.append(("open", self.ctype, self.task_message.content)) - return self - - async def __aexit__(self, *args: Any) -> bool: - await self.close() - return False - - async def close(self) -> None: - self.sink.append(("close", self.ctype)) - - async def stream_update(self, update: Any) -> Any: - self.sink.append(("delta", self.ctype, update)) - return update - - -class _FakeStreaming: - def __init__(self) -> None: - self.sink: list[Any] = [] - self.messages_opened: list[Any] = [] - - def streaming_task_message_context( - self, - task_id: str, - initial_content: Any, - streaming_mode: str = "coalesced", - created_at: Any = None, - ) -> _FakeCtx: - ctype = getattr(initial_content, "type", None) or "" - self.messages_opened.append(initial_content) - return _FakeCtx(self.sink, ctype, initial_content) - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -async def _run_auto_send_turn( - events: list[StreamTaskMessage], - trace_id: str | None = None, - parent_span_id: str | None = None, - fake_tracing: FakeTracing | None = None, -) -> tuple[TurnResult, _FakeStreaming]: - fake_streaming = _FakeStreaming() - tracer: SpanTracer | bool | None = None - if trace_id and fake_tracing is not None: - tracer = SpanTracer( - trace_id=trace_id, - parent_span_id=parent_span_id, - task_id="task1", - tracing=fake_tracing, - ) - - turn = OpenAITurn(stream=_canonical_stream(events), model="gpt-4o") - emitter = UnifiedEmitter( - task_id="task1", - trace_id=trace_id, - parent_span_id=parent_span_id, - tracer=tracer if tracer is not None else False, - streaming=fake_streaming, - ) - result = await emitter.auto_send_turn(turn) - return result, fake_streaming - - -# --------------------------------------------------------------------------- -# Tests: message order and content -# --------------------------------------------------------------------------- - - -class TestAsyncAutoSendMessageOrder: - async def test_tool_request_pushed_before_tool_response(self) -> None: - _, fake_streaming = await _run_auto_send_turn(_tool_then_text_events()) - message_types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] - assert "tool_request" in message_types - assert message_types.index("tool_request") < message_types.index("tool_response") - - async def test_text_pushed_last(self) -> None: - _, fake_streaming = await _run_auto_send_turn(_tool_then_text_events()) - message_types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] - assert message_types[-1] == "text", f"Expected last message type=text, got {message_types}" - - async def test_exactly_three_messages(self) -> None: - _, fake_streaming = await _run_auto_send_turn(_tool_then_text_events()) - assert len(fake_streaming.messages_opened) == 3, ( - f"Expected 3 messages, got {[getattr(m, 'type', None) for m in fake_streaming.messages_opened]}" - ) - - -class TestAsyncAutoSendContentVerification: - async def test_tool_request_content(self) -> None: - _, fake_streaming = await _run_auto_send_turn(_tool_then_text_events()) - tool_reqs = [m for m in fake_streaming.messages_opened if isinstance(m, ToolRequestContent)] - assert len(tool_reqs) == 1 - assert tool_reqs[0].name == "get_weather" - - async def test_tool_response_content(self) -> None: - _, fake_streaming = await _run_auto_send_turn(_tool_then_text_events()) - tool_resps = [m for m in fake_streaming.messages_opened if isinstance(m, ToolResponseContent)] - assert len(tool_resps) == 1 - assert "72F" in str(tool_resps[0].content) - assert tool_resps[0].name == "get_weather" - - async def test_tool_call_ids_match(self) -> None: - _, fake_streaming = await _run_auto_send_turn(_tool_then_text_events()) - tool_req = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolRequestContent)) - tool_resp = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolResponseContent)) - assert tool_req.tool_call_id == tool_resp.tool_call_id - - -class TestAsyncAutoSendFinalTextAndUsage: - async def test_final_text_matches_deltas(self) -> None: - result, _ = await _run_auto_send_turn(_tool_then_text_events()) - assert result.final_text == "Sunny and 72F." - - async def test_turn_result_has_usage(self) -> None: - """An injected canonical stream has no run to read usage from, so usage - carries only the model name (input_tokens stays None).""" - result, _ = await _run_auto_send_turn(_tool_then_text_events()) - assert result.usage is not None - assert result.usage.model == "gpt-4o" - - async def test_context_lifecycle_open_then_close(self) -> None: - _, fake_streaming = await _run_auto_send_turn(_tool_then_text_events()) - opens = [e for e in fake_streaming.sink if e[0] == "open"] - closes = [e for e in fake_streaming.sink if e[0] == "close"] - assert len(opens) == len(closes) == 3 - - async def test_usage_populated_from_result_path(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Via the OpenAITurn result/converter path, aggregated token usage is - surfaced on TurnResult.usage after the stream is consumed. - - Mirrors the OpenAI turn test: a fake RunResultStreaming exposes - raw_responses with a Usage, and the converter is monkeypatched to a - passthrough so the canonical text stream is delivered while usage is read - from raw_responses. - """ - import agentex.lib.adk._modules._openai_turn as turn_mod - - canonical: list[StreamTaskMessage] = [ - StreamTaskMessageStart( - type="start", index=0, content=TextContent(type="text", author="agent", content="") - ), - StreamTaskMessageDelta(type="delta", index=0, delta=TextDelta(type="text", text_delta="hi")), - StreamTaskMessageDone(type="done", index=0), - ] - - class _FakeResult: - def __init__(self) -> None: - self.raw_responses = [ - type("R", (), {"usage": Usage(requests=2, input_tokens=8, output_tokens=4, total_tokens=12)})() - ] - - def stream_events(self): # type: ignore[no-untyped-def] - return _canonical_stream(canonical) - - async def _passthrough(stream): # type: ignore[no-untyped-def] - async for e in stream: - yield e - - monkeypatch.setattr(turn_mod, "convert_openai_to_agentex_events", _passthrough) - - turn = OpenAITurn(result=_FakeResult(), model="gpt-4o") - emitter = UnifiedEmitter( - task_id="task1", - trace_id=None, - parent_span_id=None, - tracer=False, - streaming=_FakeStreaming(), - ) - result = await emitter.auto_send_turn(turn) - - assert result.final_text == "hi" - assert result.usage.model == "gpt-4o" - assert result.usage.num_llm_calls == 2 - assert result.usage.input_tokens == 8 - assert result.usage.output_tokens == 4 - assert result.usage.total_tokens == 12 - - -class TestAsyncAutoSendSpanDerivation: - async def test_tool_span_derived_on_async_path(self) -> None: - fake_tracing = FakeTracing() - await _run_auto_send_turn( - _tool_then_text_events(), - trace_id="trace1", - parent_span_id="parent", - fake_tracing=fake_tracing, - ) - assert len(fake_tracing.started) == 1 - assert fake_tracing.started[0][0] == "get_weather" - assert len(fake_tracing.ended) == 1 diff --git a/tests/lib/core/harness/test_harness_openai_sync.py b/tests/lib/core/harness/test_harness_openai_sync.py deleted file mode 100644 index 34a9b72c6..000000000 --- a/tests/lib/core/harness/test_harness_openai_sync.py +++ /dev/null @@ -1,323 +0,0 @@ -"""Integration test: sync (HTTP-yield) channel with an OpenAI-agents turn. - -Exercises the unified harness surface (UnifiedEmitter.yield_turn + OpenAITurn) -with hand-built canonical StreamTaskMessage* streams so the test runs fully -offline (no API keys, no live OpenAI Agents run, no Agentex server). - -Why an injected canonical stream --------------------------------- -OpenAI's native ``RunResultStreaming`` events are heavy SDK objects; the -``OpenAITurn`` accepts a pre-built canonical ``stream=`` of StreamTaskMessage* -events that bypasses ``convert_openai_to_agentex_events``. The shapes used here -are copied verbatim from the OpenAI converter contract exercised by -``tests/lib/core/harness/conformance/test_openai_conformance.py`` (tool calls -are Full(ToolRequestContent) + Full(ToolResponseContent); reasoning is -Start(ReasoningContent) + Delta + Done). This keeps the canonical stream -faithful to what the live converter produces while staying offline. - -What is tested --------------- -- The sync handler forwards StreamTaskMessage* events verbatim in canonical - order: tool_request (Full) -> tool_response (Full) -> text (Start+Delta+Done). -- Final accumulated text equals the seeded text deltas. -- With a trace_id + fake tracing, a tool span is opened (OpenSpan) on - Full(ToolRequestContent) and closed (CloseSpan) on the matching - Full(ToolResponseContent), and a reasoning span is opened/closed for a - reasoning segment โ€” proving the SpanDeriver is wired on the yield path. - -What is NOT covered without live infrastructure ------------------------------------------------ -- Actual HTTP streaming over the ACP sync endpoint. -- A real ``Runner.run_streamed`` execution / live OpenAI model behaviour. -- ``convert_openai_to_agentex_events`` over real SDK events (covered by the - OpenAI turn + conformance suites). - -See also: test_harness_openai_async.py and test_harness_openai_temporal.py. -""" - -from __future__ import annotations - -from typing import Any, override - -from agentex.types.text_delta import TextDelta -from agentex.types.text_content import TextContent -from agentex.lib.core.harness.types import OpenSpan, CloseSpan, StreamTaskMessage -from agentex.lib.core.harness.tracer import SpanTracer -from agentex.types.reasoning_content import ReasoningContent -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.lib.adk._modules._openai_turn import OpenAITurn -from agentex.types.reasoning_content_delta import ReasoningContentDelta - -from ._fakes import FakeTracing - -# --------------------------------------------------------------------------- -# Canonical event fixtures (copied from the OpenAI converter contract) -# --------------------------------------------------------------------------- - - -def _tool_then_text_events() -> list[StreamTaskMessage]: - """A tool round-trip followed by a final text reply. - - Mirrors the OpenAI converter's tool path: a Full(ToolRequestContent) for the - call and a Full(ToolResponseContent) for the result (matched by tool_call_id), - then a streamed text answer. - """ - return [ - StreamTaskMessageFull( - type="full", - index=0, - content=ToolRequestContent( - type="tool_request", - author="agent", - tool_call_id="call_1", - name="get_weather", - arguments={"city": "Paris"}, - ), - ), - StreamTaskMessageFull( - type="full", - index=1, - content=ToolResponseContent( - type="tool_response", - author="agent", - tool_call_id="call_1", - name="get_weather", - content="The weather in Paris is sunny and 72F", - ), - ), - StreamTaskMessageStart( - type="start", - index=2, - content=TextContent(type="text", author="agent", content=""), - ), - StreamTaskMessageDelta(type="delta", index=2, delta=TextDelta(type="text", text_delta="Sunny ")), - StreamTaskMessageDelta(type="delta", index=2, delta=TextDelta(type="text", text_delta="and 72F.")), - StreamTaskMessageDone(type="done", index=2), - ] - - -def _reasoning_events() -> list[StreamTaskMessage]: - """A reasoning segment: Start(ReasoningContent) + Delta + Done.""" - return [ - StreamTaskMessageStart( - type="start", - index=0, - content=ReasoningContent(type="reasoning", author="agent", summary=["Thinking..."]), - ), - StreamTaskMessageDelta( - type="delta", - index=0, - delta=ReasoningContentDelta(type="reasoning_content", content_index=0, content_delta="step 1"), - ), - StreamTaskMessageDone(type="done", index=0), - ] - - -async def _canonical_stream(events: list[StreamTaskMessage]): - for e in events: - yield e - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -async def _run_yield_turn( - events: list[StreamTaskMessage], - trace_id: str | None = None, - parent_span_id: str | None = None, - fake_tracing: FakeTracing | None = None, -) -> list[Any]: - """Drive the sync (yield) path and collect all yielded events.""" - tracer: SpanTracer | bool | None = None - if trace_id and fake_tracing is not None: - tracer = SpanTracer( - trace_id=trace_id, - parent_span_id=parent_span_id, - task_id="task1", - tracing=fake_tracing, - ) - - turn = OpenAITurn(stream=_canonical_stream(events), model="gpt-4o") - emitter = UnifiedEmitter( - task_id="task1", - trace_id=trace_id, - parent_span_id=parent_span_id, - tracer=tracer if tracer is not None else False, - ) - return [ev async for ev in emitter.yield_turn(turn)] - - -# --------------------------------------------------------------------------- -# Tests: event order and content -# --------------------------------------------------------------------------- - - -class TestSyncYieldEventOrder: - async def test_tool_request_precedes_tool_response(self) -> None: - events = await _run_yield_turn(_tool_then_text_events()) - content_types = [ - getattr(getattr(ev, "content", None), "type", None) - for ev in events - if isinstance(ev, (StreamTaskMessageStart, StreamTaskMessageFull)) - ] - assert "tool_request" in content_types - assert "tool_response" in content_types - assert content_types.index("tool_request") < content_types.index("tool_response") - - async def test_text_appears_after_tool_response(self) -> None: - events = await _run_yield_turn(_tool_then_text_events()) - tool_resp_pos = next( - i - for i, ev in enumerate(events) - if isinstance(ev, StreamTaskMessageFull) - and getattr(getattr(ev, "content", None), "type", None) == "tool_response" - ) - text_start_pos = next( - i - for i, ev in enumerate(events) - if isinstance(ev, StreamTaskMessageStart) and getattr(getattr(ev, "content", None), "type", None) == "text" - ) - assert tool_resp_pos < text_start_pos - - async def test_tool_response_carries_weather_result(self) -> None: - events = await _run_yield_turn(_tool_then_text_events()) - full_responses = [ - ev - for ev in events - if isinstance(ev, StreamTaskMessageFull) and isinstance(getattr(ev, "content", None), ToolResponseContent) - ] - assert len(full_responses) == 1 - tool_response = full_responses[0].content - assert isinstance(tool_response, ToolResponseContent) - assert "72F" in str(tool_response.content) - assert tool_response.name == "get_weather" - - async def test_accumulated_text_matches_deltas(self) -> None: - events = await _run_yield_turn(_tool_then_text_events()) - accumulated = "".join( - ev.delta.text_delta - for ev in events - if isinstance(ev, StreamTaskMessageDelta) and isinstance(ev.delta, TextDelta) and ev.delta.text_delta - ) - assert accumulated == "Sunny and 72F." - - async def test_every_start_has_matching_done(self) -> None: - events = await _run_yield_turn(_tool_then_text_events()) - starts = {ev.index for ev in events if isinstance(ev, StreamTaskMessageStart)} - dones = {ev.index for ev in events if isinstance(ev, StreamTaskMessageDone)} - assert starts == dones, f"Unmatched Start/Done indices: starts={starts} dones={dones}" - - -# --------------------------------------------------------------------------- -# Tests: span derivation on the yield path -# --------------------------------------------------------------------------- - - -class TestSyncYieldSpanDerivation: - async def test_tool_span_opened_and_closed(self) -> None: - """Full(ToolRequestContent) opens a tool span; Full(ToolResponseContent) closes it.""" - fake_tracing = FakeTracing() - await _run_yield_turn( - _tool_then_text_events(), - trace_id="trace1", - parent_span_id="parent-span", - fake_tracing=fake_tracing, - ) - - assert len(fake_tracing.started) == 1, "Expected exactly one tool span opened" - assert len(fake_tracing.ended) == 1, "Expected exactly one tool span closed" - name, parent_id, _ = fake_tracing.started[0] - assert name == "get_weather" - assert parent_id == "parent-span" - - async def test_tool_span_output_is_tool_result(self) -> None: - fake_tracing = FakeTracing() - await _run_yield_turn( - _tool_then_text_events(), - trace_id="trace1", - parent_span_id="parent-span", - fake_tracing=fake_tracing, - ) - name, output = fake_tracing.ended[0] - assert name == "get_weather" - assert "72F" in str(output) - - async def test_reasoning_span_opened_and_closed(self) -> None: - """A reasoning segment opens and closes a reasoning span.""" - fake_tracing = FakeTracing() - await _run_yield_turn( - _reasoning_events(), - trace_id="trace1", - parent_span_id="parent-span", - fake_tracing=fake_tracing, - ) - assert fake_tracing.started_names == ["reasoning"] - assert len(fake_tracing.ended) == 1 - - async def test_no_trace_id_means_no_spans(self) -> None: - fake_tracing = FakeTracing() - turn = OpenAITurn(stream=_canonical_stream(_tool_then_text_events()), model="gpt-4o") - emitter = UnifiedEmitter( - task_id="task1", - trace_id=None, - parent_span_id=None, - tracing=fake_tracing, - ) - [_ async for _ in emitter.yield_turn(turn)] - assert fake_tracing.started == [] - assert fake_tracing.ended == [] - - async def test_tracer_false_suppresses_spans(self) -> None: - fake_tracing = FakeTracing() - turn = OpenAITurn(stream=_canonical_stream(_tool_then_text_events()), model="gpt-4o") - emitter = UnifiedEmitter( - task_id="task1", - trace_id="trace1", - parent_span_id="parent-span", - tracer=False, - tracing=fake_tracing, - ) - [_ async for _ in emitter.yield_turn(turn)] - assert fake_tracing.started == [] - assert fake_tracing.ended == [] - - async def test_span_signal_types(self) -> None: - """The signals received by the tracer are OpenSpan then CloseSpan.""" - received_signals: list[Any] = [] - - class _RecordingTracer(SpanTracer): - @override - async def handle(self, signal: Any) -> None: - received_signals.append(signal) - await super().handle(signal) - - fake_tracing = FakeTracing() - tracer = _RecordingTracer( - trace_id="trace1", - parent_span_id="parent", - task_id="task1", - tracing=fake_tracing, - ) - turn = OpenAITurn(stream=_canonical_stream(_tool_then_text_events()), model="gpt-4o") - emitter = UnifiedEmitter( - task_id="task1", - trace_id="trace1", - parent_span_id="parent", - tracer=tracer, - ) - [_ async for _ in emitter.yield_turn(turn)] - - assert len(received_signals) == 2 - assert isinstance(received_signals[0], OpenSpan) - assert isinstance(received_signals[1], CloseSpan) - assert received_signals[0].name == "get_weather" diff --git a/tests/lib/core/harness/test_harness_openai_temporal.py b/tests/lib/core/harness/test_harness_openai_temporal.py deleted file mode 100644 index 61cda37ef..000000000 --- a/tests/lib/core/harness/test_harness_openai_temporal.py +++ /dev/null @@ -1,195 +0,0 @@ -"""Integration test: Temporal channel with an OpenAI-agents turn, offline. - -In a Temporal OpenAI deployment (see -examples/tutorials/10_async/10_temporal/120_openai_agents), the OpenAI Agents -SDK run executes inside a Temporal activity. Each turn's canonical stream is -delivered to Redis via the SAME ``UnifiedEmitter.auto_send_turn`` path used by -the non-temporal async channel โ€” the only temporal-specific concern at the -harness boundary is that the activity stamps messages with a deterministic -``created_at`` (e.g. ``workflow.now()``) so replay is deterministic. - -There is no dedicated ``stream_openai_events`` temporal helper (unlike -langgraph's ``stream_langgraph_events``); the temporal OpenAI agent builds an -``OpenAITurn`` and calls ``auto_send_turn`` directly inside the activity. This -suite therefore exercises the auto_send path plus the temporal-only contract: -``created_at`` is threaded through to every streaming context. - -What is tested --------------- -- The canonical message sequence (tool_request -> tool_response -> text) is - delivered via auto_send_turn, exactly as inside a Temporal activity. -- ``created_at`` passed to ``auto_send_turn`` is forwarded to every - ``streaming_task_message_context`` call (deterministic timestamping). -- Final text is returned from the turn. - -What is NOT covered without live infrastructure ------------------------------------------------ -- Temporal scheduling (workflow.signal -> activity dispatch). -- Temporal durability / replay behaviour. -- Redis streaming (requires a running Redis instance). -- A real Runner.run_streamed execution / live OpenAI model behaviour. - -See also: test_harness_openai_sync.py and test_harness_openai_async.py. -""" - -from __future__ import annotations - -from typing import Any -from datetime import datetime, timezone - -from agentex.types.text_delta import TextDelta -from agentex.types.task_message import TaskMessage -from agentex.types.text_content import TextContent -from agentex.lib.core.harness.types import StreamTaskMessage -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.lib.adk._modules._openai_turn import OpenAITurn - - -def _tool_then_text_events() -> list[StreamTaskMessage]: - return [ - StreamTaskMessageFull( - type="full", - index=0, - content=ToolRequestContent( - type="tool_request", - author="agent", - tool_call_id="call_1", - name="get_weather", - arguments={"city": "Paris"}, - ), - ), - StreamTaskMessageFull( - type="full", - index=1, - content=ToolResponseContent( - type="tool_response", - author="agent", - tool_call_id="call_1", - name="get_weather", - content="The weather in Paris is sunny and 72F", - ), - ), - StreamTaskMessageStart( - type="start", - index=2, - content=TextContent(type="text", author="agent", content=""), - ), - StreamTaskMessageDelta(type="delta", index=2, delta=TextDelta(type="text", text_delta="Sunny ")), - StreamTaskMessageDelta(type="delta", index=2, delta=TextDelta(type="text", text_delta="and 72F.")), - StreamTaskMessageDone(type="done", index=2), - ] - - -async def _canonical_stream(events: list[StreamTaskMessage]): - for e in events: - yield e - - -# --------------------------------------------------------------------------- -# Fake streaming backend that records the created_at it receives -# --------------------------------------------------------------------------- - - -class _FakeCtx: - def __init__(self, ctype: str, initial_content: Any) -> None: - self.ctype = ctype - self.task_message = TaskMessage(id="msg-1", task_id="task1", content=initial_content) - - async def __aenter__(self) -> "_FakeCtx": - return self - - async def __aexit__(self, *args: Any) -> bool: - await self.close() - return False - - async def close(self) -> None: - pass - - async def stream_update(self, update: Any) -> Any: - return update - - -class _FakeStreaming: - def __init__(self) -> None: - self.messages_opened: list[Any] = [] - self.created_ats: list[Any] = [] - - def streaming_task_message_context( - self, - task_id: str, - initial_content: Any, - streaming_mode: str = "coalesced", - created_at: Any = None, - ) -> _FakeCtx: - ctype = getattr(initial_content, "type", None) or "" - self.messages_opened.append(initial_content) - self.created_ats.append(created_at) - return _FakeCtx(ctype, initial_content) - - -async def _run_activity(events: list[StreamTaskMessage], created_at: datetime | None) -> tuple[Any, _FakeStreaming]: - """Mirror the temporal activity body: build an OpenAITurn and auto_send it.""" - fake_streaming = _FakeStreaming() - turn = OpenAITurn(stream=_canonical_stream(events), model="gpt-4o") - emitter = UnifiedEmitter( - task_id="task1", - trace_id=None, - parent_span_id=None, - tracer=False, - streaming=fake_streaming, - ) - result = await emitter.auto_send_turn(turn, created_at=created_at) - return result, fake_streaming - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -class TestTemporalActivityMessageOrder: - async def test_canonical_sequence_delivered(self) -> None: - _, fake_streaming = await _run_activity(_tool_then_text_events(), created_at=None) - types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] - assert "tool_request" in types - assert "tool_response" in types - assert types.index("tool_request") < types.index("tool_response") - assert types[-1] == "text" - - async def test_final_text_returned(self) -> None: - result, _ = await _run_activity(_tool_then_text_events(), created_at=None) - assert result.final_text == "Sunny and 72F." - - -class TestTemporalCreatedAtThreading: - """created_at is forwarded to every streaming context (deterministic replay).""" - - async def test_created_at_threaded_to_all_contexts(self) -> None: - fixed = datetime(2026, 6, 22, 12, 0, 0, tzinfo=timezone.utc) - _, fake_streaming = await _run_activity(_tool_then_text_events(), created_at=fixed) - assert len(fake_streaming.created_ats) == 3 - assert all(ts == fixed for ts in fake_streaming.created_ats), ( - f"Expected every context stamped with {fixed}, got {fake_streaming.created_ats}" - ) - - async def test_default_created_at_is_none(self) -> None: - """When the activity does not stamp a timestamp, contexts see None.""" - _, fake_streaming = await _run_activity(_tool_then_text_events(), created_at=None) - assert all(ts is None for ts in fake_streaming.created_ats) - - async def test_created_at_is_deterministic_across_runs(self) -> None: - """Two runs with the same created_at stamp identical timestamps โ€” the - determinism the Temporal channel relies on for replay.""" - fixed = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc) - _, first = await _run_activity(_tool_then_text_events(), created_at=fixed) - _, second = await _run_activity(_tool_then_text_events(), created_at=fixed) - assert first.created_ats == second.created_ats - assert all(ts == fixed for ts in first.created_ats) diff --git a/tests/lib/core/harness/test_harness_pydantic_ai_async.py b/tests/lib/core/harness/test_harness_pydantic_ai_async.py deleted file mode 100644 index 4b6b86415..000000000 --- a/tests/lib/core/harness/test_harness_pydantic_ai_async.py +++ /dev/null @@ -1,330 +0,0 @@ -"""Integration test: async (Redis-streaming) channel with a pydantic-ai agent. - -Exercises the unified harness surface (UnifiedEmitter.auto_send_turn + PydanticAITurn) -with a minimal pydantic-ai agent backed by TestModel so the test runs fully -offline (no API keys, no Redis, no Agentex server). - -Agent description ------------------ -Same single-tool agent as the sync test: ``get_weather(city: str) -> str`` -returning "sunny and 72F". TestModel is configured to call the tool once then -produce a fixed text reply. - -The async path uses the bare PydanticAITurn (no coalescing): the foundation -auto_send delivers streamed tool-request Start+ToolRequestDelta+Done messages -natively, so no coalescing wrapper is needed. - -What is tested --------------- -- The async handler pushes the correct sequence of messages to the fake streaming - backend: tool_request + tool_response + text (in that order). -- final_text equals the TestModel custom output. -- With a SpanTracer, tool spans are derived and forwarded to the fake tracing - backend (streamed tool-request delivery now triggers span derivation on the - async path). - -What is NOT covered without live infrastructure ------------------------------------------------ -- Actual Redis streaming (requires a running Redis instance). -- The ACP on_task_event_send / on_task_create / on_task_cancel lifecycle. -- Multi-turn history persistence via adk.state. -- Real LLM calls or production model behaviour. -- The full FastACP async request lifecycle. - -See also: test_harness_pydantic_ai_sync.py (span derivation with sync path) and -test_harness_pydantic_ai_temporal.py (temporal activity path). -""" - -from __future__ import annotations - -from typing import Any - -import pytest -from pydantic_ai import Agent -from pydantic_ai.models.test import TestModel - -from agentex.types.task_message import TaskMessage -from agentex.lib.core.harness.types import TurnResult -from agentex.lib.core.harness.tracer import SpanTracer -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn - -from ._fakes import FakeTracing - -# --------------------------------------------------------------------------- -# Minimal agent under test -# --------------------------------------------------------------------------- - - -def _make_agent() -> Agent: - """Build a pydantic-ai agent with one weather tool and a TestModel.""" - model = TestModel( - call_tools=["get_weather"], - custom_output_text="The weather in Paris is sunny and 72F.", - ) - agent: Agent = Agent(model) - - @agent.tool_plain - def get_weather(city: str) -> str: - """Get the current weather for a city.""" - return f"The weather in {city} is sunny and 72F" - - return agent - - -# --------------------------------------------------------------------------- -# Fake streaming backend (replaces adk.streaming; no Redis required) -# --------------------------------------------------------------------------- - - -class _FakeCtx: - """Minimal StreamingTaskMessageContext fake.""" - - def __init__(self, sink: list[Any], ctype: str, initial_content: Any) -> None: - self.sink = sink - self.ctype = ctype - self.task_message = TaskMessage(id="msg-1", task_id="task1", content=initial_content) - - async def __aenter__(self) -> "_FakeCtx": - self.sink.append(("open", self.ctype, self.task_message.content)) - return self - - async def __aexit__(self, *args: Any) -> bool: - await self.close() - return False - - async def close(self) -> None: - self.sink.append(("close", self.ctype)) - - async def stream_update(self, update: Any) -> Any: - self.sink.append(("delta", self.ctype, update)) - return update - - -class _FakeStreaming: - """Fake streaming backend; records every context lifecycle event.""" - - def __init__(self) -> None: - self.sink: list[Any] = [] - self.messages_opened: list[Any] = [] - - def streaming_task_message_context( - self, - task_id: str, - initial_content: Any, - streaming_mode: str = "coalesced", - created_at: Any = None, - ) -> _FakeCtx: - ctype = getattr(initial_content, "type", None) or "" - self.messages_opened.append(initial_content) - return _FakeCtx(self.sink, ctype, initial_content) - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -async def _run_auto_send_turn( - agent: Agent, - user_msg: str = "What is the weather in Paris?", - trace_id: str | None = None, - parent_span_id: str | None = None, - fake_tracing: FakeTracing | None = None, -) -> tuple[TurnResult, _FakeStreaming]: - """Drive the async (auto_send) path and return the TurnResult + fake streaming state.""" - fake_streaming = _FakeStreaming() - - tracer: SpanTracer | bool | None = None - if trace_id and fake_tracing is not None: - tracer = SpanTracer( - trace_id=trace_id, - parent_span_id=parent_span_id, - task_id="task1", - tracing=fake_tracing, - ) - - async with agent.run_stream_events(user_msg) as stream: - turn = PydanticAITurn( - stream, - model="test", - ) - emitter = UnifiedEmitter( - task_id="task1", - trace_id=trace_id, - parent_span_id=parent_span_id, - tracer=tracer if tracer is not None else False, - streaming=fake_streaming, - ) - result = await emitter.auto_send_turn(turn) - - return result, fake_streaming - - -# --------------------------------------------------------------------------- -# Tests: message order and content -# --------------------------------------------------------------------------- - - -class TestAsyncAutoSendMessageOrder: - """auto_send pushes messages to the streaming backend in canonical order.""" - - async def test_tool_request_pushed_first(self) -> None: - """tool_request is the first message type pushed to the streaming backend.""" - agent = _make_agent() - _, fake_streaming = await _run_auto_send_turn(agent) - - message_types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] - assert "tool_request" in message_types - assert message_types.index("tool_request") < message_types.index("tool_response"), ( - "tool_request must be pushed before tool_response" - ) - - async def test_tool_response_pushed_after_tool_request(self) -> None: - """tool_response appears after tool_request in the pushed messages.""" - agent = _make_agent() - _, fake_streaming = await _run_auto_send_turn(agent) - - message_types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] - assert "tool_response" in message_types - - async def test_text_pushed_last(self) -> None: - """Text content is the last type pushed (after tool round-trip).""" - agent = _make_agent() - _, fake_streaming = await _run_auto_send_turn(agent) - - message_types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] - assert message_types[-1] == "text", f"Expected last message type=text, got {message_types}" - - async def test_exactly_three_messages(self) -> None: - """Exactly three message contexts are opened: tool_request, tool_response, text.""" - agent = _make_agent() - _, fake_streaming = await _run_auto_send_turn(agent) - - assert len(fake_streaming.messages_opened) == 3, ( - f"Expected 3 messages (tool_request + tool_response + text), " - f"got {len(fake_streaming.messages_opened)}: " - f"{[getattr(m, 'type', None) for m in fake_streaming.messages_opened]}" - ) - - -class TestAsyncAutoSendContentVerification: - """The content pushed to the streaming backend is correct.""" - - async def test_tool_request_content(self) -> None: - """The pushed tool_request is a ToolRequestContent for get_weather.""" - agent = _make_agent() - _, fake_streaming = await _run_auto_send_turn(agent) - - tool_reqs = [m for m in fake_streaming.messages_opened if isinstance(m, ToolRequestContent)] - assert len(tool_reqs) == 1, "Expected exactly one ToolRequestContent" - assert tool_reqs[0].name == "get_weather" - - async def test_tool_response_content(self) -> None: - """The pushed tool_response is a ToolResponseContent containing the weather result.""" - agent = _make_agent() - _, fake_streaming = await _run_auto_send_turn(agent) - - tool_resps = [m for m in fake_streaming.messages_opened if isinstance(m, ToolResponseContent)] - assert len(tool_resps) == 1, "Expected exactly one ToolResponseContent" - assert isinstance(tool_resps[0].content, str) - assert "72F" in tool_resps[0].content - assert tool_resps[0].name == "get_weather" - - async def test_tool_call_ids_match(self) -> None: - """tool_request and tool_response have the same tool_call_id.""" - agent = _make_agent() - _, fake_streaming = await _run_auto_send_turn(agent) - - tool_req = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolRequestContent)) - tool_resp = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolResponseContent)) - assert tool_req.tool_call_id == tool_resp.tool_call_id, ( - "tool_request and tool_response must share the same tool_call_id" - ) - - -class TestAsyncAutoSendFinalText: - """auto_send_turn returns the accumulated text from the last text part.""" - - async def test_final_text_matches_model_output(self) -> None: - """TurnResult.final_text equals the TestModel custom_output_text.""" - agent = _make_agent() - result, _ = await _run_auto_send_turn(agent) - assert result.final_text == "The weather in Paris is sunny and 72F." - - async def test_turn_result_has_usage(self) -> None: - """TurnResult carries a TurnUsage object (may have None tokens from TestModel).""" - agent = _make_agent() - result, _ = await _run_auto_send_turn(agent) - assert result.usage is not None - - async def test_context_lifecycle_open_then_close(self) -> None: - """Every message context is opened then closed (no leak).""" - agent = _make_agent() - _, fake_streaming = await _run_auto_send_turn(agent) - - opens = [e for e in fake_streaming.sink if e[0] == "open"] - closes = [e for e in fake_streaming.sink if e[0] == "close"] - assert len(opens) == len(closes) == 3, "Each of the 3 messages must have exactly one open and one close" - - -class TestAsyncAutoSendSpanDerivation: - """Span derivation on the async path now works for streamed tool requests. - - The foundation auto_send delivers Start+ToolRequestDelta+Done natively. - The SpanDeriver opens a tool span on Done(tool_request), so the async path - derives spans just like the sync path. - """ - - async def test_tool_span_derived_on_async_path(self) -> None: - """With the bare PydanticAITurn (no coalescing), a tool span is derived - on the async/auto_send path when auto_send delivers the streamed - Start+ToolRequestDelta+Done sequence.""" - agent = _make_agent() - fake_tracing = FakeTracing() - tracer = SpanTracer( - trace_id="trace1", - parent_span_id="parent", - task_id="task1", - tracing=fake_tracing, - ) - fake_streaming = _FakeStreaming() - - async with agent.run_stream_events("What is the weather in Paris?") as stream: - turn = PydanticAITurn(stream, model="test") - emitter = UnifiedEmitter( - task_id="task1", - trace_id="trace1", - parent_span_id="parent", - tracer=tracer, - streaming=fake_streaming, - ) - await emitter.auto_send_turn(turn) - - assert len(fake_tracing.started) == 1, ( - "Expected one tool span to be started for the get_weather call." - ) - assert fake_tracing.started[0][0] == "get_weather" - assert len(fake_tracing.ended) == 1 - - -@pytest.mark.parametrize( - "user_msg", - [ - "What is the weather in Paris?", - "Tell me the weather in London.", - ], -) -async def test_async_handler_pushes_messages_for_various_inputs(user_msg: str) -> None: - """auto_send pushes at least tool_request + tool_response + text for any input.""" - agent = _make_agent() - result, fake_streaming = await _run_auto_send_turn(agent, user_msg=user_msg) - - message_types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] - assert "tool_request" in message_types - assert "tool_response" in message_types - assert "text" in message_types - assert isinstance(result.final_text, str) - assert len(result.final_text) > 0 diff --git a/tests/lib/core/harness/test_harness_pydantic_ai_sync.py b/tests/lib/core/harness/test_harness_pydantic_ai_sync.py deleted file mode 100644 index 04beea81d..000000000 --- a/tests/lib/core/harness/test_harness_pydantic_ai_sync.py +++ /dev/null @@ -1,357 +0,0 @@ -"""Integration test: sync (HTTP-yield) channel with a pydantic-ai agent. - -Exercises the unified harness surface (UnifiedEmitter.yield_turn + PydanticAITurn) -with a minimal pydantic-ai agent backed by TestModel so the test runs fully -offline (no API keys, no live infrastructure). - -Agent description ------------------ -A single-tool agent with ``get_weather(city: str) -> str`` that always returns -"sunny and 72F". TestModel is configured to call that tool once then produce -a fixed text reply, giving a deterministic event sequence. - -What is tested --------------- -- The sync handler correctly yields StreamTaskMessage* events in order: - tool_request (Start+Done) then tool_response (Full) then text (Start+Delta+Done). -- Final accumulated text equals the TestModel custom output. -- With a trace_id + fake tracing, a tool span is opened (OpenSpan) and - closed (CloseSpan) โ€” proving the SpanDeriver is wired on the yield path. - -What is NOT covered without live infrastructure ------------------------------------------------ -- Actual HTTP streaming over the ACP sync endpoint (requires a running - Agentex server + deployed agent). -- Real LLM calls or production model behaviour. -- The full FastACP request/response lifecycle. - -See also: tests/lib/core/harness/test_harness_pydantic_ai_async.py and -test_harness_pydantic_ai_temporal.py for the other two channels. -""" - -from __future__ import annotations - -from typing import Any, override - -import pytest -from pydantic_ai import Agent -from pydantic_ai.models.test import TestModel - -from agentex.types.text_delta import TextDelta -from agentex.lib.core.harness.types import OpenSpan, CloseSpan -from agentex.lib.core.harness.tracer import SpanTracer -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageStart, -) -from agentex.types.tool_response_content import ToolResponseContent -from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn - -from ._fakes import FakeTracing - -# --------------------------------------------------------------------------- -# Minimal agent under test -# --------------------------------------------------------------------------- - - -def _make_agent() -> Agent: - """Build a pydantic-ai agent with one weather tool and a TestModel. - - TestModel is instantiated with call_tools=['get_weather'] so it always - invokes the tool once, then emits custom_output_text as the reply. - """ - model = TestModel( - call_tools=["get_weather"], - custom_output_text="The weather in Paris is sunny and 72F.", - ) - agent: Agent = Agent(model) - - @agent.tool_plain - def get_weather(city: str) -> str: - """Get the current weather for a city.""" - return f"The weather in {city} is sunny and 72F" - - return agent - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -async def _run_yield_turn( - agent: Agent, - user_msg: str = "What is the weather in Paris?", - trace_id: str | None = None, - parent_span_id: str | None = None, - fake_tracing: FakeTracing | None = None, -) -> list[Any]: - """Drive the sync (yield) path and collect all yielded events.""" - tracer: SpanTracer | bool | None = None - if trace_id and fake_tracing is not None: - tracer = SpanTracer( - trace_id=trace_id, - parent_span_id=parent_span_id, - task_id="task1", - tracing=fake_tracing, - ) - - events: list[Any] = [] - async with agent.run_stream_events(user_msg) as stream: - turn = PydanticAITurn(stream, model="test") - emitter = UnifiedEmitter( - task_id="task1", - trace_id=trace_id, - parent_span_id=parent_span_id, - tracer=tracer if tracer is not None else False, - ) - events = [ev async for ev in emitter.yield_turn(turn)] - return events - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -class TestSyncYieldEventOrder: - """The yield channel forwards events in canonical order.""" - - async def test_tool_request_precedes_tool_response(self) -> None: - """tool_request events appear before the tool_response Full event.""" - agent = _make_agent() - events = await _run_yield_turn(agent) - - content_types = [ - getattr(getattr(ev, "content", None), "type", None) - for ev in events - if isinstance(ev, (StreamTaskMessageStart, StreamTaskMessageFull)) - ] - assert "tool_request" in content_types - assert "tool_response" in content_types - tool_req_idx = content_types.index("tool_request") - tool_resp_idx = content_types.index("tool_response") - assert tool_req_idx < tool_resp_idx, "tool_request must appear before tool_response in the event stream" - - async def test_text_appears_after_tool_response(self) -> None: - """Text content (Start/Done) comes after the tool_response Full event.""" - agent = _make_agent() - events = await _run_yield_turn(agent) - - full_types = [ - getattr(getattr(ev, "content", None), "type", None) - for ev in events - if isinstance(ev, StreamTaskMessageFull) - ] - start_types = [ - getattr(getattr(ev, "content", None), "type", None) - for ev in events - if isinstance(ev, StreamTaskMessageStart) - ] - - assert "tool_response" in full_types - assert "text" in start_types - - tool_resp_pos = next( - i - for i, ev in enumerate(events) - if isinstance(ev, StreamTaskMessageFull) - and getattr(getattr(ev, "content", None), "type", None) == "tool_response" - ) - text_start_pos = next( - i - for i, ev in enumerate(events) - if isinstance(ev, StreamTaskMessageStart) and getattr(getattr(ev, "content", None), "type", None) == "text" - ) - assert tool_resp_pos < text_start_pos - - async def test_tool_response_carries_weather_result(self) -> None: - """The ToolResponseContent contains the get_weather return value.""" - agent = _make_agent() - events = await _run_yield_turn(agent) - - full_events = [ - ev - for ev in events - if isinstance(ev, StreamTaskMessageFull) and isinstance(getattr(ev, "content", None), ToolResponseContent) - ] - assert len(full_events) >= 1, "Expected at least one tool_response Full event" - tool_response = full_events[0].content - assert isinstance(tool_response, ToolResponseContent) - assert isinstance(tool_response.content, str) - assert "72F" in tool_response.content - assert tool_response.name == "get_weather" - - async def test_accumulated_text_matches_model_output(self) -> None: - """Accumulated text deltas equal the TestModel custom_output_text.""" - from agentex.types.task_message_update import StreamTaskMessageDelta - - agent = _make_agent() - events = await _run_yield_turn(agent) - - accumulated = "".join( - ev.delta.text_delta - for ev in events - if isinstance(ev, StreamTaskMessageDelta) and isinstance(ev.delta, TextDelta) and ev.delta.text_delta - ) - assert accumulated == "The weather in Paris is sunny and 72F." - - async def test_every_start_has_matching_done(self) -> None: - """Every StreamTaskMessageStart has a corresponding StreamTaskMessageDone.""" - agent = _make_agent() - events = await _run_yield_turn(agent) - - starts = {ev.index for ev in events if isinstance(ev, StreamTaskMessageStart)} - dones = {ev.index for ev in events if isinstance(ev, StreamTaskMessageDone)} - assert starts == dones, f"Unmatched Start/Done indices: starts={starts} dones={dones}" - - -class TestSyncYieldSpanDerivation: - """SpanDeriver is wired on the yield path; tool spans are opened/closed.""" - - async def test_tool_span_opened_and_closed(self) -> None: - """One tool span is opened and closed per tool call.""" - agent = _make_agent() - fake_tracing = FakeTracing() - tracer = SpanTracer( - trace_id="trace1", - parent_span_id="parent-span", - task_id="task1", - tracing=fake_tracing, - ) - - async with agent.run_stream_events("What is the weather in Paris?") as stream: - turn = PydanticAITurn(stream, model="test") - emitter = UnifiedEmitter( - task_id="task1", - trace_id="trace1", - parent_span_id="parent-span", - tracer=tracer, - ) - await emitter.yield_turn(turn).__anext__.__self__ if False else None - [_ async for _ in emitter.yield_turn(turn)] - - assert len(fake_tracing.started) == 1, "Expected exactly one tool span opened" - assert len(fake_tracing.ended) == 1, "Expected exactly one tool span closed" - span_name, parent_id, _ = fake_tracing.started[0] - assert span_name == "get_weather" - assert parent_id == "parent-span" - - async def test_tool_span_output_is_tool_result(self) -> None: - """The closed tool span's output equals the tool's return value.""" - agent = _make_agent() - fake_tracing = FakeTracing() - tracer = SpanTracer( - trace_id="trace1", - parent_span_id="parent-span", - task_id="task1", - tracing=fake_tracing, - ) - - async with agent.run_stream_events("What is the weather in Paris?") as stream: - turn = PydanticAITurn(stream, model="test") - emitter = UnifiedEmitter( - task_id="task1", - trace_id="trace1", - parent_span_id="parent-span", - tracer=tracer, - ) - [_ async for _ in emitter.yield_turn(turn)] - - name, output = fake_tracing.ended[0] - assert name == "get_weather" - assert output is not None - assert "72F" in str(output) - - async def test_no_trace_id_means_no_spans(self) -> None: - """With trace_id=None, no spans are derived (emitter disables tracing).""" - agent = _make_agent() - fake_tracing = FakeTracing() - - async with agent.run_stream_events("What is the weather in Paris?") as stream: - turn = PydanticAITurn(stream, model="test") - emitter = UnifiedEmitter( - task_id="task1", - trace_id=None, - parent_span_id=None, - tracing=fake_tracing, - ) - [_ async for _ in emitter.yield_turn(turn)] - - assert fake_tracing.started == [] - assert fake_tracing.ended == [] - - async def test_tracer_false_suppresses_spans(self) -> None: - """tracer=False disables span derivation regardless of trace_id.""" - agent = _make_agent() - fake_tracing = FakeTracing() - - async with agent.run_stream_events("What is the weather in Paris?") as stream: - turn = PydanticAITurn(stream, model="test") - emitter = UnifiedEmitter( - task_id="task1", - trace_id="trace1", - parent_span_id="parent-span", - tracer=False, - tracing=fake_tracing, - ) - [_ async for _ in emitter.yield_turn(turn)] - - assert fake_tracing.started == [] - assert fake_tracing.ended == [] - - async def test_span_signal_types(self) -> None: - """The signals received by the tracer are OpenSpan then CloseSpan.""" - from agentex.lib.core.harness.tracer import SpanTracer as RealTracer - - received_signals: list[Any] = [] - - class _RecordingTracer(RealTracer): - @override - async def handle(self, signal: Any) -> None: - received_signals.append(signal) - await super().handle(signal) - - fake_tracing = FakeTracing() - tracer = _RecordingTracer( - trace_id="trace1", - parent_span_id="parent", - task_id="task1", - tracing=fake_tracing, - ) - - agent = _make_agent() - async with agent.run_stream_events("What is the weather in Paris?") as stream: - turn = PydanticAITurn(stream, model="test") - emitter = UnifiedEmitter( - task_id="task1", - trace_id="trace1", - parent_span_id="parent", - tracer=tracer, - ) - [_ async for _ in emitter.yield_turn(turn)] - - assert len(received_signals) == 2 - assert isinstance(received_signals[0], OpenSpan) - assert isinstance(received_signals[1], CloseSpan) - assert received_signals[0].name == "get_weather" - - -@pytest.mark.parametrize( - "user_msg", - [ - "What is the weather in Paris?", - "Tell me the weather in London.", - ], -) -async def test_sync_handler_produces_events_for_various_inputs(user_msg: str) -> None: - """Yield path produces at least a tool_response Full for any user message.""" - agent = _make_agent() - events = await _run_yield_turn(agent, user_msg=user_msg) - - full_event_types = [ - getattr(getattr(ev, "content", None), "type", None) for ev in events if isinstance(ev, StreamTaskMessageFull) - ] - assert "tool_response" in full_event_types diff --git a/tests/lib/core/harness/test_harness_pydantic_ai_temporal.py b/tests/lib/core/harness/test_harness_pydantic_ai_temporal.py deleted file mode 100644 index 0ead8e832..000000000 --- a/tests/lib/core/harness/test_harness_pydantic_ai_temporal.py +++ /dev/null @@ -1,370 +0,0 @@ -"""Integration test: Temporal-backed pydantic-ai agent, offline. - -Exercises the core of the Temporal pydantic-ai harness path โ€” the -event_stream_handler activity โ€” with a TemporalAgent backed by TestModel so the -test runs fully offline (no Temporal server, no Redis, no API keys). - -Architecture overview ---------------------- -In a real Temporal deployment the pydantic-ai Temporal harness runs like this: - - HTTP POST /task/event/send - -> @workflow.signal on At110PydanticAiWorkflow - -> temporal_agent.run(user_message, deps=TaskDeps(...)) - internally schedules: - 1. request_activity (LLM HTTP call โ€” recorded by Temporal) - 2. call_tool_activity (for each tool call โ€” also recorded) - 3. event_stream_handler_activity (streams events to Redis) - -The third activity is what we test here: it receives a -``RunContext[TaskDeps]`` and an ``AsyncIterable[AgentStreamEvent]`` from -pydantic-ai, calls ``stream_pydantic_ai_events`` (which internally constructs -a ``UnifiedEmitter`` + ``PydanticAITurn`` and calls ``auto_send_turn``), and -pushes the resulting messages to Redis. - -What we test ------------ -Since ``TemporalAgent.run_stream_events`` works offline with TestModel (it does -not schedule Temporal activities โ€” it runs in-process), we can: - -1. Build a TemporalAgent with TestModel. -2. Call ``run_stream_events`` on it directly, just as the event_stream_handler - would see the event iterable. -3. Feed that stream into ``stream_pydantic_ai_events`` backed by a fake streaming - backend, and assert the canonical message sequence. - -This covers the full inner harness chain that the Temporal workflow exercises, -minus the Temporal scheduling/durability layer itself. - -What is NOT covered without live infrastructure ------------------------------------------------ -- Temporal scheduling (the workflow.signal -> activity dispatch chain). -- Temporal durability guarantees and replay behaviour. -- Redis streaming (requires a running Redis instance). -- Multi-turn history (pydantic-ai message_history round-tripping via Temporal - workflow state). -- Real LLM calls or production model behaviour. -- The full temporal_agent.run(...) path, which schedules activities and cannot - run without a connected Temporal client. - -To test with live infrastructure: spin up Temporal + Redis + the ACP server + -the Temporal worker, then use the AsyncAgentex client to create a task, send a -message, and poll for messages โ€” exactly as the existing examples/tutorials/ -10_async/10_temporal/110_pydantic_ai/tests/test_agent.py does. -""" - -from __future__ import annotations - -from typing import Any - -import pytest -from pydantic import BaseModel -from pydantic_ai import Agent -from pydantic_ai.models.test import TestModel -from pydantic_ai.durable_exec.temporal import TemporalAgent - -from agentex.types.task_message import TaskMessage -from agentex.lib.core.harness.emitter import UnifiedEmitter -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn - -# --------------------------------------------------------------------------- -# Agent under test (mirrors examples/tutorials/10_async/10_temporal/110_pydantic_ai) -# --------------------------------------------------------------------------- - - -class TaskDeps(BaseModel): - """Per-run dependencies injected via RunContext.deps.""" - - task_id: str - parent_span_id: str | None = None - - -def _make_temporal_agent() -> TemporalAgent[TaskDeps, str]: - """Build a TemporalAgent with TestModel and one weather tool. - - The underlying pydantic-ai Agent is constructed with TaskDeps as the - deps_type, mirroring the real temporal tutorial agent. TestModel makes - the run deterministic and offline. - """ - model = TestModel( - call_tools=["get_weather"], - custom_output_text="The weather in Paris is sunny and 72F.", - ) - base: Agent[TaskDeps, str] = Agent(model, deps_type=TaskDeps) - - @base.tool_plain - def get_weather(city: str) -> str: - """Get the current weather for a city.""" - return f"The weather in {city} is sunny and 72F" - - return TemporalAgent(base, name="test_temporal_agent") - - -# --------------------------------------------------------------------------- -# Fake streaming backend -# --------------------------------------------------------------------------- - - -class _FakeCtx: - def __init__(self, sink: list[Any], ctype: str, initial_content: Any) -> None: - self.sink = sink - self.ctype = ctype - self.task_message = TaskMessage(id="msg-1", task_id="task1", content=initial_content) - - async def __aenter__(self) -> "_FakeCtx": - self.sink.append(("open", self.ctype, self.task_message.content)) - return self - - async def __aexit__(self, *args: Any) -> bool: - await self.close() - return False - - async def close(self) -> None: - self.sink.append(("close", self.ctype)) - - async def stream_update(self, update: Any) -> Any: - self.sink.append(("delta", self.ctype, update)) - return update - - -class _FakeStreaming: - def __init__(self) -> None: - self.sink: list[Any] = [] - self.messages_opened: list[Any] = [] - - def streaming_task_message_context( - self, - task_id: str, - initial_content: Any, - streaming_mode: str = "coalesced", - created_at: Any = None, - ) -> _FakeCtx: - ctype = getattr(initial_content, "type", None) or "" - self.messages_opened.append(initial_content) - return _FakeCtx(self.sink, ctype, initial_content) - - -# --------------------------------------------------------------------------- -# Helpers: the event_stream_handler pattern tested offline -# --------------------------------------------------------------------------- - - -async def _run_event_stream_handler( - temporal_agent: TemporalAgent[TaskDeps, str], - user_msg: str = "What is the weather in Paris?", - task_id: str = "task1", -) -> _FakeStreaming: - """Simulate the event_stream_handler activity offline. - - In production the event_stream_handler receives the event stream from - pydantic-ai's model activity and calls stream_pydantic_ai_events. - Here we obtain the stream directly from run_stream_events (which works - offline with TestModel) and forward it to stream_pydantic_ai_events backed - by a fake streaming backend. - - This is equivalent to: - async def event_handler(ctx: RunContext[TaskDeps], events: AsyncIterable[AgentStreamEvent]) -> None: - await stream_pydantic_ai_events(events, ctx.deps.task_id) - but without requiring a running Temporal server. - """ - fake_streaming = _FakeStreaming() - - async with temporal_agent.run_stream_events(user_msg) as stream: - await _fake_stream_pydantic_ai_events(stream, task_id, fake_streaming) - - return fake_streaming - - -async def _fake_stream_pydantic_ai_events( - stream: Any, - task_id: str, - fake_streaming: _FakeStreaming, -) -> str: - """Like stream_pydantic_ai_events but uses an injected fake streaming backend. - - Mirrors the exact chain that stream_pydantic_ai_events uses internally: - PydanticAITurn(stream) - + UnifiedEmitter.auto_send_turn(turn) - but with the fake backend injected so no Redis is needed. - """ - turn = PydanticAITurn(stream, model=None) - emitter = UnifiedEmitter( - task_id=task_id, - trace_id=None, - parent_span_id=None, - tracer=False, - streaming=fake_streaming, - ) - result = await emitter.auto_send_turn(turn) - return result.final_text - - -# --------------------------------------------------------------------------- -# Tests: TemporalAgent + event_stream_handler pattern -# --------------------------------------------------------------------------- - - -class TestTemporalEventStreamHandlerMessageOrder: - """The event_stream_handler pushes messages in canonical order.""" - - async def test_tool_request_before_tool_response(self) -> None: - """tool_request is pushed before tool_response.""" - temporal_agent = _make_temporal_agent() - fake_streaming = await _run_event_stream_handler(temporal_agent) - - types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] - assert "tool_request" in types - assert "tool_response" in types - assert types.index("tool_request") < types.index("tool_response") - - async def test_text_is_last(self) -> None: - """Text content is pushed last (after the tool round-trip).""" - temporal_agent = _make_temporal_agent() - fake_streaming = await _run_event_stream_handler(temporal_agent) - - types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] - assert types[-1] == "text" - - async def test_exactly_three_messages(self) -> None: - """Exactly tool_request + tool_response + text are pushed.""" - temporal_agent = _make_temporal_agent() - fake_streaming = await _run_event_stream_handler(temporal_agent) - - assert len(fake_streaming.messages_opened) == 3, ( - f"Expected 3 messages, got {len(fake_streaming.messages_opened)}: " - f"{[getattr(m, 'type', None) for m in fake_streaming.messages_opened]}" - ) - - -class TestTemporalEventStreamHandlerContent: - """Content verification for the messages pushed by the event_stream_handler.""" - - async def test_tool_request_is_get_weather(self) -> None: - """The pushed tool_request is for the get_weather function.""" - temporal_agent = _make_temporal_agent() - fake_streaming = await _run_event_stream_handler(temporal_agent) - - tool_reqs = [m for m in fake_streaming.messages_opened if isinstance(m, ToolRequestContent)] - assert len(tool_reqs) == 1 - assert tool_reqs[0].name == "get_weather" - - async def test_tool_response_contains_weather_result(self) -> None: - """The pushed tool_response contains the get_weather return value.""" - temporal_agent = _make_temporal_agent() - fake_streaming = await _run_event_stream_handler(temporal_agent) - - tool_resps = [m for m in fake_streaming.messages_opened if isinstance(m, ToolResponseContent)] - assert len(tool_resps) == 1 - assert isinstance(tool_resps[0].content, str) - assert "72F" in tool_resps[0].content - assert tool_resps[0].name == "get_weather" - - async def test_tool_call_ids_match(self) -> None: - """tool_request and tool_response share the same tool_call_id.""" - temporal_agent = _make_temporal_agent() - fake_streaming = await _run_event_stream_handler(temporal_agent) - - tool_req = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolRequestContent)) - tool_resp = next(m for m in fake_streaming.messages_opened if isinstance(m, ToolResponseContent)) - assert tool_req.tool_call_id == tool_resp.tool_call_id - - -class TestTemporalFinalText: - """stream_pydantic_ai_events returns the correct final text.""" - - async def test_final_text_matches_model_output(self) -> None: - """The returned final text equals the TestModel custom_output_text.""" - temporal_agent = _make_temporal_agent() - fake_streaming = _FakeStreaming() - - async with temporal_agent.run_stream_events("What is the weather in Paris?") as stream: - final = await _fake_stream_pydantic_ai_events(stream, "task1", fake_streaming) - - assert final == "The weather in Paris is sunny and 72F." - - async def test_context_lifecycle_complete(self) -> None: - """Every opened streaming context is also closed.""" - temporal_agent = _make_temporal_agent() - fake_streaming = await _run_event_stream_handler(temporal_agent) - - opens = [e for e in fake_streaming.sink if e[0] == "open"] - closes = [e for e in fake_streaming.sink if e[0] == "close"] - assert len(opens) == len(closes), "Every opened context must be closed" - - -class TestTemporalAgentStreamEventsOffline: - """TemporalAgent.run_stream_events produces the expected raw pydantic-ai events. - - This verifies that the TemporalAgent wrapper does not suppress event stream - delivery when used with TestModel, so the event_stream_handler pattern is - meaningful offline. - """ - - async def test_run_stream_events_yields_tool_call_and_text(self) -> None: - """TemporalAgent.run_stream_events with TestModel yields tool + text events.""" - - temporal_agent = _make_temporal_agent() - collected: list[Any] = [] - - async with temporal_agent.run_stream_events("What is the weather in Paris?") as stream: - async for ev in stream: - collected.append(ev) - - event_types = {type(ev).__name__ for ev in collected} - assert "FunctionToolResultEvent" in event_types, "Expected FunctionToolResultEvent proving tool call ran" - assert "PartDeltaEvent" in event_types or "PartEndEvent" in event_types, ( - "Expected text part events in the stream" - ) - - async def test_run_stream_events_contains_tool_result(self) -> None: - """The raw event stream contains a FunctionToolResultEvent with the tool output.""" - from pydantic_ai.messages import FunctionToolResultEvent - - temporal_agent = _make_temporal_agent() - - async with temporal_agent.run_stream_events("What is the weather in Paris?") as stream: - events = [ev async for ev in stream] - - tool_results = [ev for ev in events if isinstance(ev, FunctionToolResultEvent)] - assert len(tool_results) >= 1 - assert isinstance(tool_results[0].part.content, str) - assert "72F" in tool_results[0].part.content - - -class TestTemporalLiveInfraNote: - """Placeholder tests documenting what requires live Temporal infrastructure. - - These tests are skipped by design. They document the gap between what the - offline tests cover and what a full integration test would exercise. - """ - - @pytest.mark.skip( - reason=( - "Requires live Temporal server + Redis + ACP server + worker. " - "See examples/tutorials/10_async/10_temporal/110_pydantic_ai/tests/test_agent.py " - "for the live integration test that exercises this path end-to-end." - ) - ) - async def test_temporal_workflow_full_round_trip(self) -> None: - """Full Temporal workflow: create_task -> send_event -> poll_messages.""" - pass # Covered by the live tutorial test - - -@pytest.mark.parametrize( - "user_msg", - [ - "What is the weather in Paris?", - "Tell me the weather in London.", - ], -) -async def test_temporal_handler_pushes_messages_for_various_inputs(user_msg: str) -> None: - """event_stream_handler pushes tool_request + tool_response + text for any input.""" - temporal_agent = _make_temporal_agent() - fake_streaming = await _run_event_stream_handler(temporal_agent, user_msg=user_msg) - - types = [getattr(m, "type", None) for m in fake_streaming.messages_opened] - assert "tool_request" in types - assert "tool_response" in types - assert "text" in types diff --git a/tests/lib/core/harness/test_span_derivation.py b/tests/lib/core/harness/test_span_derivation.py deleted file mode 100644 index 6376dc0c6..000000000 --- a/tests/lib/core/harness/test_span_derivation.py +++ /dev/null @@ -1,365 +0,0 @@ -from agentex.types.text_content import TextContent -from agentex.lib.core.harness.types import OpenSpan, CloseSpan -from agentex.types.reasoning_content import ReasoningContent -from agentex.types.tool_request_delta import ToolRequestDelta -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageDelta, - StreamTaskMessageStart, -) -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.types.reasoning_content_delta import ReasoningContentDelta -from agentex.types.reasoning_summary_delta import ReasoningSummaryDelta -from agentex.lib.core.harness.span_derivation import SpanDeriver - - -def _signals(deriver, events): - out = [] - for e in events: - out.extend(deriver.observe(e)) - out.extend(deriver.flush()) - return out - - -def _tool_req(idx, tcid, name, args): - return StreamTaskMessageStart( - type="start", - index=idx, - content=ToolRequestContent(type="tool_request", author="agent", tool_call_id=tcid, name=name, arguments=args), - ) - - -def test_text_only_yields_no_spans(): - d = SpanDeriver() - events = [ - StreamTaskMessageStart(type="start", index=0, content=TextContent(type="text", author="agent", content="")), - StreamTaskMessageDelta(type="delta", index=0, delta=None), - StreamTaskMessageDone(type="done", index=0), - ] - assert _signals(d, events) == [] - - -def test_single_tool_opens_on_done_closes_on_response(): - d = SpanDeriver() - events = [ - _tool_req(0, "call_1", "Bash", {"cmd": "ls"}), - StreamTaskMessageDone(type="done", index=0), - StreamTaskMessageFull( - type="full", - index=1, - content=ToolResponseContent( - type="tool_response", author="agent", tool_call_id="call_1", name="Bash", content="files" - ), - ), - ] - sigs = _signals(d, events) - assert sigs == [ - OpenSpan(key="call_1", kind="tool", name="Bash", input={"cmd": "ls"}), - CloseSpan(key="call_1", output="files", is_complete=True), - ] - # No status reported -> CloseSpan carries is_error=None. - assert sigs[1].is_error is None - - -def test_tool_response_is_error_propagates_to_close_span(): - """ToolResponseContent.is_error flows onto the CloseSpan so a derived tool - span can be marked as a failure (AGX1-371).""" - d = SpanDeriver() - events = [ - _tool_req(0, "call_err", "Bash", {"cmd": "false"}), - StreamTaskMessageDone(type="done", index=0), - StreamTaskMessageFull( - type="full", - index=1, - content=ToolResponseContent( - type="tool_response", - author="agent", - tool_call_id="call_err", - name="Bash", - content="boom", - is_error=True, - ), - ), - ] - sigs = _signals(d, events) - assert sigs == [ - OpenSpan(key="call_err", kind="tool", name="Bash", input={"cmd": "false"}), - CloseSpan(key="call_err", output="boom", is_complete=True, is_error=True), - ] - - -def test_reasoning_opens_on_start_closes_on_done(): - d = SpanDeriver() - events = [ - StreamTaskMessageStart( - type="start", index=0, content=ReasoningContent(type="reasoning", author="agent", summary=[], content=[]) - ), - StreamTaskMessageDone(type="done", index=0), - ] - sigs = _signals(d, events) - assert sigs[0] == OpenSpan(key="reasoning:0", kind="reasoning", name="reasoning", input={}) - # No deltas -> nothing to record, so output stays None (not an empty string). - assert sigs[1] == CloseSpan(key="reasoning:0", output=None, is_complete=True) - - -def test_reasoning_content_deltas_recorded_as_output(): - """The chain-of-thought streamed via ReasoningContentDelta lands on the - reasoning span's output (previously dropped, leaving the span blank).""" - d = SpanDeriver() - events = [ - StreamTaskMessageStart( - type="start", index=0, content=ReasoningContent(type="reasoning", author="agent", summary=[], content=[]) - ), - StreamTaskMessageDelta( - type="delta", - index=0, - delta=ReasoningContentDelta(type="reasoning_content", content_index=0, content_delta="Let me "), - ), - StreamTaskMessageDelta( - type="delta", - index=0, - delta=ReasoningContentDelta(type="reasoning_content", content_index=0, content_delta="think."), - ), - StreamTaskMessageDone(type="done", index=0), - ] - sigs = _signals(d, events) - assert sigs[0] == OpenSpan(key="reasoning:0", kind="reasoning", name="reasoning", input={}) - assert sigs[1] == CloseSpan(key="reasoning:0", output="Let me think.", is_complete=True) - - -def test_reasoning_summary_deltas_recorded_as_output(): - """Reasoning-model summary deltas (o-series) also land on the span output.""" - d = SpanDeriver() - events = [ - StreamTaskMessageStart( - type="start", index=0, content=ReasoningContent(type="reasoning", author="agent", summary=[], content=[]) - ), - StreamTaskMessageDelta( - type="delta", - index=0, - delta=ReasoningSummaryDelta(type="reasoning_summary", summary_index=0, summary_delta="Summary text"), - ), - StreamTaskMessageDone(type="done", index=0), - ] - sigs = _signals(d, events) - assert sigs[1] == CloseSpan(key="reasoning:0", output="Summary text", is_complete=True) - - -def test_reasoning_text_seeded_from_start_content(): - """A non-streaming harness that carries the full thinking on the Start - content still records it as output even with no deltas.""" - d = SpanDeriver() - events = [ - StreamTaskMessageStart( - type="start", - index=0, - content=ReasoningContent(type="reasoning", author="agent", summary=[], content=["full thought"]), - ), - StreamTaskMessageDone(type="done", index=0), - ] - sigs = _signals(d, events) - assert sigs[1] == CloseSpan(key="reasoning:0", output="full thought", is_complete=True) - - -def test_reasoning_unclosed_flushes_with_text(): - """An unclosed reasoning span flushes incomplete but still carries its text.""" - d = SpanDeriver() - events = [ - StreamTaskMessageStart( - type="start", index=0, content=ReasoningContent(type="reasoning", author="agent", summary=[], content=[]) - ), - StreamTaskMessageDelta( - type="delta", - index=0, - delta=ReasoningContentDelta(type="reasoning_content", content_index=0, content_delta="partial"), - ), - ] - sigs = _signals(d, events) - assert sigs[-1] == CloseSpan(key="reasoning:0", output="partial", is_complete=False) - - -def test_parallel_tools_pair_by_tool_call_id(): - d = SpanDeriver() - events = [ - _tool_req(0, "a", "T1", {}), - _tool_req(1, "b", "T2", {}), - StreamTaskMessageDone(type="done", index=0), - StreamTaskMessageDone(type="done", index=1), - StreamTaskMessageFull( - type="full", - index=2, - content=ToolResponseContent( - type="tool_response", author="agent", tool_call_id="b", name="T2", content="rb" - ), - ), - StreamTaskMessageFull( - type="full", - index=3, - content=ToolResponseContent( - type="tool_response", author="agent", tool_call_id="a", name="T1", content="ra" - ), - ), - ] - sigs = _signals(d, events) - opens = [s for s in sigs if isinstance(s, OpenSpan)] - closes = [s for s in sigs if isinstance(s, CloseSpan)] - assert {o.key for o in opens} == {"a", "b"} - assert [c.key for c in closes] == ["b", "a"] - assert all(c.is_complete for c in closes) - - -def test_streamed_args_accumulate_into_open_input(): - d = SpanDeriver() - events = [ - StreamTaskMessageStart( - type="start", - index=0, - content=ToolRequestContent( - type="tool_request", author="agent", tool_call_id="c", name="Bash", arguments={} - ), - ), - StreamTaskMessageDelta( - type="delta", - index=0, - delta=ToolRequestDelta(type="tool_request", tool_call_id="c", name="Bash", arguments_delta='{"cmd":'), - ), - StreamTaskMessageDelta( - type="delta", - index=0, - delta=ToolRequestDelta(type="tool_request", tool_call_id="c", name="Bash", arguments_delta='"ls"}'), - ), - StreamTaskMessageDone(type="done", index=0), - ] - sigs = _signals(d, events) - assert sigs[0] == OpenSpan(key="c", kind="tool", name="Bash", input={"cmd": "ls"}) - - -def test_unclosed_tool_closed_incomplete_on_flush(): - d = SpanDeriver() - events = [ - _tool_req(0, "x", "Bash", {}), - StreamTaskMessageDone(type="done", index=0), - ] - sigs = _signals(d, events) - assert sigs[0] == OpenSpan(key="x", kind="tool", name="Bash", input={}) - assert sigs[1] == CloseSpan(key="x", output=None, is_complete=False) - - -def test_none_index_is_skipped(): - d = SpanDeriver() - events = [ - StreamTaskMessageStart( - type="start", - index=None, - content=ToolRequestContent( - type="tool_request", author="agent", tool_call_id="n", name="Bash", arguments={} - ), - ), - StreamTaskMessageDone(type="done", index=None), - ] - assert _signals(d, events) == [] - - -def test_orphan_tool_response_ignored(): - d = SpanDeriver() - events = [ - StreamTaskMessageFull( - type="full", - index=0, - content=ToolResponseContent( - type="tool_response", author="agent", tool_call_id="z", name="Bash", content="r" - ), - ), - ] - assert _signals(d, events) == [] - - -def test_full_tool_request_opens_span(): - """Full(ToolRequestContent) must open a tool span (for LangGraph-style harnesses).""" - d = SpanDeriver() - events = [ - StreamTaskMessageFull( - type="full", - index=0, - content=ToolRequestContent( - type="tool_request", - author="agent", - tool_call_id="call_x", - name="Bash", - arguments={"cmd": "ls"}, - ), - ), - ] - sigs = _signals(d, events) - assert sigs[0] == OpenSpan(key="call_x", kind="tool", name="Bash", input={"cmd": "ls"}) - assert sigs[1] == CloseSpan(key="call_x", output=None, is_complete=False) - - -def test_full_tool_request_and_response_paired(): - """Full(ToolRequestContent) + Full(ToolResponseContent) produces a complete span pair.""" - d = SpanDeriver() - events = [ - StreamTaskMessageFull( - type="full", - index=0, - content=ToolRequestContent( - type="tool_request", - author="agent", - tool_call_id="call_y", - name="Grep", - arguments={}, - ), - ), - StreamTaskMessageFull( - type="full", - index=1, - content=ToolResponseContent( - type="tool_response", - author="agent", - tool_call_id="call_y", - name="Grep", - content="result", - ), - ), - ] - sigs = _signals(d, events) - assert sigs == [ - OpenSpan(key="call_y", kind="tool", name="Grep", input={}), - CloseSpan(key="call_y", output="result", is_complete=True), - ] - - -def test_full_tool_request_does_not_double_open(): - """A Full(ToolRequestContent) for an already-open tool_call_id is a no-op.""" - d = SpanDeriver() - events = [ - StreamTaskMessageStart( - type="start", - index=0, - content=ToolRequestContent( - type="tool_request", - author="agent", - tool_call_id="call_z", - name="X", - arguments={}, - ), - ), - StreamTaskMessageDone(type="done", index=0), - StreamTaskMessageFull( - type="full", - index=1, - content=ToolRequestContent( - type="tool_request", - author="agent", - tool_call_id="call_z", - name="X", - arguments={}, - ), - ), - ] - sigs = _signals(d, events) - opens = [s for s in sigs if isinstance(s, OpenSpan)] - assert len(opens) == 1 - assert opens[0].key == "call_z" diff --git a/tests/lib/core/harness/test_tracer.py b/tests/lib/core/harness/test_tracer.py deleted file mode 100644 index 9bd17b90c..000000000 --- a/tests/lib/core/harness/test_tracer.py +++ /dev/null @@ -1,98 +0,0 @@ -from typing import override - -import pytest - -from agentex.lib.core.harness.types import OpenSpan, CloseSpan -from agentex.lib.core.harness.tracer import SpanTracer - -from ._fakes import FakeTracing - - -@pytest.mark.asyncio -async def test_open_then_close_starts_and_ends_span(): - fake = FakeTracing() - tracer = SpanTracer(trace_id="t1", parent_span_id="p1", tracing=fake) - await tracer.handle(OpenSpan(key="call_1", kind="tool", name="Bash", input={"cmd": "ls"})) - await tracer.handle(CloseSpan(key="call_1", output="files", is_complete=True)) - assert fake.started == [("Bash", "p1", {"cmd": "ls"})] - # A plain-string output is wrapped in a dict (SGP spans require an object). - assert fake.ended == [("Bash", {"output": "files"})] - - -@pytest.mark.asyncio -async def test_non_dict_payloads_are_wrapped_in_a_dict(): - """SGP spans reject scalar input/output with a 422; the tracer wraps any - non-dict payload so reasoning spans (string output) are not dropped.""" - fake = FakeTracing() - tracer = SpanTracer(trace_id="t1", parent_span_id="p1", tracing=fake) - await tracer.handle(OpenSpan(key="reasoning:0", kind="reasoning", name="reasoning", input={})) - await tracer.handle(CloseSpan(key="reasoning:0", output="chain of thought", is_complete=True)) - # Empty-dict input stays a dict; string output is wrapped. - assert fake.started == [("reasoning", "p1", {})] - assert fake.ended == [("reasoning", {"output": "chain of thought"})] - - -@pytest.mark.asyncio -async def test_dict_and_none_payloads_pass_through_unchanged(): - fake = FakeTracing() - tracer = SpanTracer(trace_id="t1", parent_span_id="p1", tracing=fake) - await tracer.handle(OpenSpan(key="c", kind="tool", name="T", input={"a": 1})) - await tracer.handle(CloseSpan(key="c", output={"result": "x"}, is_complete=True)) - await tracer.handle(OpenSpan(key="d", kind="tool", name="U", input={})) - await tracer.handle(CloseSpan(key="d", output=None, is_complete=False)) - assert fake.ended == [("T", {"result": "x"}), ("U", None)] - - -@pytest.mark.asyncio -async def test_close_records_is_error_on_span_data(): - """A CloseSpan carrying is_error records the status on span.data (AGX1-371).""" - fake = FakeTracing() - tracer = SpanTracer(trace_id="t1", parent_span_id="p1", tracing=fake) - await tracer.handle(OpenSpan(key="call_err", kind="tool", name="Bash", input={})) - await tracer.handle(CloseSpan(key="call_err", output="boom", is_complete=True, is_error=True)) - assert fake.ended_spans[0].data == {"is_error": True} - - -@pytest.mark.asyncio -async def test_close_without_status_leaves_span_data_untouched(): - """is_error=None (no status reported) must not write to span.data.""" - fake = FakeTracing() - tracer = SpanTracer(trace_id="t1", parent_span_id="p1", tracing=fake) - await tracer.handle(OpenSpan(key="call_1", kind="tool", name="Bash", input={})) - await tracer.handle(CloseSpan(key="call_1", output="files", is_complete=True)) - assert fake.ended_spans[0].data is None - - -@pytest.mark.asyncio -async def test_no_trace_id_is_noop(): - fake = FakeTracing() - tracer = SpanTracer(trace_id="", parent_span_id=None, tracing=fake) - await tracer.handle(OpenSpan(key="k", kind="tool", name="X")) - await tracer.handle(CloseSpan(key="k")) - assert fake.started == [] and fake.ended == [] - - -@pytest.mark.asyncio -async def test_tracing_failure_is_swallowed(): - class _Boom(FakeTracing): - @override - async def start_span(self, **kw): - raise RuntimeError("backend down") - - tracer = SpanTracer(trace_id="t1", parent_span_id="p1", tracing=_Boom()) - # Must not raise. - await tracer.handle(OpenSpan(key="k", kind="tool", name="X")) - await tracer.handle(CloseSpan(key="k")) - assert tracer._open == {} - - -@pytest.mark.asyncio -async def test_duplicate_open_replaces_silently(): - fake = FakeTracing() - tracer = SpanTracer(trace_id="t1", parent_span_id="p1", tracing=fake) - await tracer.handle(OpenSpan(key="k", kind="tool", name="A")) - await tracer.handle(OpenSpan(key="k", kind="tool", name="B")) - await tracer.handle(CloseSpan(key="k")) - # Both opens started spans, but only the second ("B") is closed. - assert [name for name, _, _ in fake.started] == ["A", "B"] - assert fake.ended == [("B", None)] diff --git a/tests/lib/core/harness/test_types.py b/tests/lib/core/harness/test_types.py deleted file mode 100644 index 68bc89ce2..000000000 --- a/tests/lib/core/harness/test_types.py +++ /dev/null @@ -1,53 +0,0 @@ -from typing import AsyncIterator - -from agentex.lib.core.harness.types import ( - OpenSpan, - CloseSpan, - TurnUsage, - TurnResult, - HarnessTurn, - StreamTaskMessage, -) - - -def test_open_close_span_construct(): - o = OpenSpan(key="call_1", kind="tool", name="Bash", input={"cmd": "ls"}) - c = CloseSpan(key="call_1", output="files", is_complete=True) - assert o.key == c.key == "call_1" - assert o.kind == "tool" - assert c.is_complete is True - - -def test_turn_usage_defaults_are_none(): - u = TurnUsage(model="claude-opus-4-6") - assert u.model == "claude-opus-4-6" - assert u.input_tokens is None - assert u.num_tool_calls == 0 - - -def test_turn_result_wraps_usage(): - r = TurnResult(final_text="hi", usage=TurnUsage(model="m")) - assert r.final_text == "hi" - assert r.usage.model == "m" - - -def test_close_span_defaults(): - c = CloseSpan(key="x") - assert c.output is None - assert c.is_complete is True - - -def test_harness_turn_runtime_check(): - class _Turn: - @property - def events(self) -> AsyncIterator[StreamTaskMessage]: - async def _gen() -> AsyncIterator[StreamTaskMessage]: - if False: - yield # pragma: no cover - - return _gen() - - def usage(self) -> TurnUsage: - return TurnUsage(model="m") - - assert isinstance(_Turn(), HarnessTurn) is True diff --git a/tests/lib/core/harness/test_yield_delivery.py b/tests/lib/core/harness/test_yield_delivery.py deleted file mode 100644 index 21c93a95c..000000000 --- a/tests/lib/core/harness/test_yield_delivery.py +++ /dev/null @@ -1,78 +0,0 @@ -import pytest - -from agentex.lib.core.harness.tracer import SpanTracer -from agentex.types.task_message_update import ( - StreamTaskMessageDone, - StreamTaskMessageFull, - StreamTaskMessageStart, -) -from agentex.types.tool_request_content import ToolRequestContent -from agentex.types.tool_response_content import ToolResponseContent -from agentex.lib.core.harness.yield_delivery import yield_events - -from ._fakes import FakeTracing - - -async def _gen(events): - for e in events: - yield e - - -@pytest.mark.asyncio -async def test_yield_passes_events_through_and_traces(): - fake = FakeTracing() - tracer = SpanTracer(trace_id="t", parent_span_id="p", tracing=fake) - events = [ - StreamTaskMessageStart( - type="start", - index=0, - content=ToolRequestContent( - type="tool_request", author="agent", tool_call_id="c", name="Bash", arguments={} - ), - ), - StreamTaskMessageDone(type="done", index=0), - StreamTaskMessageFull( - type="full", - index=1, - content=ToolResponseContent( - type="tool_response", author="agent", tool_call_id="c", name="Bash", content="ok" - ), - ), - ] - out = [e async for e in yield_events(_gen(events), tracer=tracer)] - assert out == events # passthrough unchanged - assert fake.started_names == ["Bash"] # span derived + opened - # String tool output is wrapped in a dict (SGP spans require an object). - assert fake.ended_outputs == [{"output": "ok"}] # span closed with response - - -@pytest.mark.asyncio -async def test_yield_without_tracer_is_pure_passthrough(): - events = [ - StreamTaskMessageDone(type="done", index=0), - ] - out = [e async for e in yield_events(_gen(events), tracer=None)] - assert out == events - - -@pytest.mark.asyncio -async def test_flush_runs_on_early_close(): - fake = FakeTracing() - tracer = SpanTracer(trace_id="t", parent_span_id="p", tracing=fake) - events = [ - StreamTaskMessageStart( - type="start", - index=0, - content=ToolRequestContent( - type="tool_request", author="agent", tool_call_id="c", name="Bash", arguments={} - ), - ), - StreamTaskMessageDone(type="done", index=0), - # response intentionally never arrives - ] - gen = yield_events(_gen(events), tracer=tracer) - first = await gen.__anext__() # Start - second = await gen.__anext__() # Done -> tool span opens here - await gen.aclose() # triggers the finally -> flush() - assert fake.started_names == ["Bash"] - assert fake.ended_outputs == [None] # flush closed the unpaired span (incomplete, no output) diff --git a/tests/lib/core/services/__init__.py b/tests/lib/core/services/__init__.py 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 a8068f307..000000000 --- a/tests/lib/core/services/adk/test_streaming.py +++ /dev/null @@ -1,599 +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 ( - StreamTaskMessageFull, - StreamTaskMessageDelta, -) -from agentex.lib.core.services.adk.streaming import ( - CoalescingBuffer, - StreamingTaskMessageContext, - _can_merge, - _merge_pair, - _delta_char_len, - _merge_consecutive, -) - - -@pytest.fixture -def task_message() -> TaskMessage: - return TaskMessage( - id="m1", - task_id="t1", - content=TextContent(author="agent", content="", format="markdown"), - streaming_status="IN_PROGRESS", - ) - - -def _text(tm: TaskMessage, s: str) -> StreamTaskMessageDelta: - return StreamTaskMessageDelta( - parent_task_message=tm, - delta=TextDelta(type="text", text_delta=s), - type="delta", - ) - - -def _reasoning_summary(tm: TaskMessage, idx: int, s: str) -> StreamTaskMessageDelta: - return StreamTaskMessageDelta( - parent_task_message=tm, - delta=ReasoningSummaryDelta(type="reasoning_summary", summary_index=idx, summary_delta=s), - type="delta", - ) - - -async def _make_context(streaming_mode: str) -> tuple[StreamingTaskMessageContext, MagicMock, TaskMessage]: - tm = TaskMessage( - id="m1", - task_id="t1", - content=TextContent(author="agent", content="", format="markdown"), - streaming_status="IN_PROGRESS", - ) - svc = MagicMock() - svc.stream_update = AsyncMock() - client = MagicMock() - client.messages.create = AsyncMock(return_value=tm) - client.messages.update = AsyncMock() - ctx = StreamingTaskMessageContext( - task_id="t1", - initial_content=TextContent(author="agent", content="", format="markdown"), - agentex_client=client, - streaming_service=svc, - streaming_mode=streaming_mode, # type: ignore[arg-type] - ) - await ctx.open() - return ctx, svc, tm - - -class TestDeltaCharLen: - def test_text_delta(self) -> None: - assert _delta_char_len(TextDelta(type="text", text_delta="hello")) == 5 - - def test_reasoning_summary_delta(self) -> None: - assert ( - _delta_char_len(ReasoningSummaryDelta(type="reasoning_summary", summary_index=0, summary_delta="abc")) == 3 - ) - - def test_none_delta_is_zero(self) -> None: - assert _delta_char_len(None) == 0 - - def test_empty_string_delta(self) -> None: - assert _delta_char_len(TextDelta(type="text", text_delta="")) == 0 - - -class TestCanMerge: - def test_same_text_type(self) -> None: - a = TextDelta(type="text", text_delta="a") - b = TextDelta(type="text", text_delta="b") - assert _can_merge(a, b) is True - - def test_different_types_never_merge(self) -> None: - text = TextDelta(type="text", text_delta="a") - data = DataDelta(type="data", data_delta="b") - assert _can_merge(text, data) is False - - def test_reasoning_summary_same_index_merges(self) -> None: - a = ReasoningSummaryDelta(type="reasoning_summary", summary_index=0, summary_delta="x") - b = ReasoningSummaryDelta(type="reasoning_summary", summary_index=0, summary_delta="y") - assert _can_merge(a, b) is True - - def test_reasoning_summary_different_index_blocks_merge(self) -> None: - a = ReasoningSummaryDelta(type="reasoning_summary", summary_index=0, summary_delta="x") - b = ReasoningSummaryDelta(type="reasoning_summary", summary_index=1, summary_delta="y") - assert _can_merge(a, b) is False - - def test_tool_request_same_call_id_merges(self) -> None: - a = ToolRequestDelta(type="tool_request", tool_call_id="c1", name="t", arguments_delta="{") - b = ToolRequestDelta(type="tool_request", tool_call_id="c1", name="t", arguments_delta="}") - assert _can_merge(a, b) is True - - def test_tool_request_different_call_id_blocks_merge(self) -> None: - a = ToolRequestDelta(type="tool_request", tool_call_id="c1", name="t", arguments_delta="{") - b = ToolRequestDelta(type="tool_request", tool_call_id="c2", name="t", arguments_delta="}") - assert _can_merge(a, b) is False - - -class TestMergePair: - def test_text_concatenates(self) -> None: - merged = _merge_pair( - TextDelta(type="text", text_delta="Hello "), - TextDelta(type="text", text_delta="world"), - ) - assert isinstance(merged, TextDelta) - assert merged.text_delta == "Hello world" - - def test_reasoning_summary_concatenates_and_keeps_index(self) -> None: - merged = _merge_pair( - ReasoningSummaryDelta(type="reasoning_summary", summary_index=2, summary_delta="hello "), - ReasoningSummaryDelta(type="reasoning_summary", summary_index=2, summary_delta="world"), - ) - assert isinstance(merged, ReasoningSummaryDelta) - assert merged.summary_index == 2 - assert merged.summary_delta == "hello world" - - def test_tool_response_concatenates_and_keeps_call_id(self) -> None: - merged = _merge_pair( - ToolResponseDelta(type="tool_response", tool_call_id="c1", name="t", content_delta="part1 "), - ToolResponseDelta(type="tool_response", tool_call_id="c1", name="t", content_delta="part2"), - ) - assert isinstance(merged, ToolResponseDelta) - assert merged.tool_call_id == "c1" - assert merged.content_delta == "part1 part2" - - def test_handles_none_string_fields(self) -> None: - """Pydantic allows the *_delta fields to be None; merge must coerce to empty.""" - merged = _merge_pair( - TextDelta(type="text", text_delta=None), - TextDelta(type="text", text_delta="late"), - ) - assert isinstance(merged, TextDelta) - assert merged.text_delta == "late" - - -class TestMergeConsecutive: - def test_pure_text_collapses_to_one(self, task_message: TaskMessage) -> None: - deltas = [_text(task_message, s) for s in ["Hello", " ", "world", "!"]] - merged = _merge_consecutive(deltas) - assert len(merged) == 1 - assert merged[0].delta is not None - assert isinstance(merged[0].delta, TextDelta) - assert merged[0].delta.text_delta == "Hello world!" - - def test_empty_input_returns_empty_list(self) -> None: - assert _merge_consecutive([]) == [] - - def test_single_delta_passes_through(self, task_message: TaskMessage) -> None: - deltas = [_text(task_message, "lone")] - merged = _merge_consecutive(deltas) - assert len(merged) == 1 - assert merged[0] is deltas[0] # same object, no merge happened - - def test_cross_channel_order_preserved_for_reasoning(self, task_message: TaskMessage) -> None: - """Consecutive same-(type, index) merges; distinct channels never reorder.""" - deltas = [ - _reasoning_summary(task_message, 0, "Let me "), - _reasoning_summary(task_message, 0, "think..."), - _reasoning_summary(task_message, 1, "Maybe "), - _reasoning_summary(task_message, 0, " Actually,"), - _reasoning_summary(task_message, 0, " yes."), - ] - merged = _merge_consecutive(deltas) - # Three groups: idx=0 run, idx=1 single, idx=0 run again โ€” order preserved. - assert len(merged) == 3 - assert merged[0].delta is not None and isinstance(merged[0].delta, ReasoningSummaryDelta) - assert merged[1].delta is not None and isinstance(merged[1].delta, ReasoningSummaryDelta) - assert merged[2].delta is not None and isinstance(merged[2].delta, ReasoningSummaryDelta) - assert merged[0].delta.summary_index == 0 - assert merged[0].delta.summary_delta == "Let me think..." - assert merged[1].delta.summary_index == 1 - assert merged[1].delta.summary_delta == "Maybe " - assert merged[2].delta.summary_index == 0 - assert merged[2].delta.summary_delta == " Actually, yes." - - def test_per_channel_concat_matches_per_token_semantics(self, task_message: TaskMessage) -> None: - """Reconstructing per-channel content from the merged stream must match - what a per-token consumer would have seen.""" - deltas = [ - _reasoning_summary(task_message, 0, "Hel"), - _reasoning_summary(task_message, 0, "lo"), - _reasoning_summary(task_message, 1, "World"), - _reasoning_summary(task_message, 0, "!"), - ] - merged = _merge_consecutive(deltas) - - per_index: dict[int, str] = {} - for u in merged: - d = u.delta - assert isinstance(d, ReasoningSummaryDelta) - per_index[d.summary_index] = per_index.get(d.summary_index, "") + (d.summary_delta or "") - - assert per_index == {0: "Hello!", 1: "World"} - - -class TestCoalescingBufferTimeWindow: - @pytest.mark.asyncio - async def test_first_delta_flushes_immediately(self, task_message: TaskMessage) -> None: - """The first-delta-immediate optimization should trip a flush in <=20ms, - well below the 50ms time window, so consumers see ``something started``.""" - flushed: list[StreamTaskMessageDelta] = [] - - async def on_flush(u: StreamTaskMessageDelta) -> None: - flushed.append(u) - - buf = CoalescingBuffer(on_flush=on_flush) - buf.start() - try: - await buf.add(_text(task_message, "hi")) - # Give the ticker a single tick to drain the signal. - await asyncio.sleep(0.020) - assert len(flushed) == 1 - assert flushed[0].delta is not None and isinstance(flushed[0].delta, TextDelta) - assert flushed[0].delta.text_delta == "hi" - finally: - await buf.close() - - @pytest.mark.asyncio - async def test_size_threshold_triggers_early_flush(self, task_message: TaskMessage) -> None: - """Adding more than MAX_BUFFERED_CHARS in one shot should flush within - a single asyncio tick, well before the 50ms timer would fire.""" - flushed: list[StreamTaskMessageDelta] = [] - - async def on_flush(u: StreamTaskMessageDelta) -> None: - flushed.append(u) - - buf = CoalescingBuffer(on_flush=on_flush) - buf.start() - try: - # Burn the first-delta-immediate slot so we're on the steady-state path. - await buf.add(_text(task_message, "x")) - await asyncio.sleep(0.020) - flushed.clear() - - # Now add 200 chars in one delta โ€” well over MAX_BUFFERED_CHARS=128. - await buf.add(_text(task_message, "A" * 200)) - await asyncio.sleep(0.010) # half the timer interval; only size can fire here - assert len(flushed) == 1 - assert flushed[0].delta is not None and isinstance(flushed[0].delta, TextDelta) - assert flushed[0].delta.text_delta == "A" * 200 - finally: - await buf.close() - - @pytest.mark.asyncio - async def test_subsequent_deltas_coalesce_within_window(self, task_message: TaskMessage) -> None: - """Three small deltas added inside one timer window should publish as - one merged delta (after the initial first-flush burns).""" - flushed: list[StreamTaskMessageDelta] = [] - - async def on_flush(u: StreamTaskMessageDelta) -> None: - flushed.append(u) - - buf = CoalescingBuffer(on_flush=on_flush) - buf.start() - try: - await buf.add(_text(task_message, "first")) # immediate flush - await asyncio.sleep(0.020) - flushed.clear() - - for chunk in ("ab", "cd", "ef"): - await buf.add(_text(task_message, chunk)) - # Wait past the 50ms window so the timer fires. - await asyncio.sleep(0.080) - # All three small deltas merge into a single publish. - assert len(flushed) == 1 - assert flushed[0].delta is not None and isinstance(flushed[0].delta, TextDelta) - assert flushed[0].delta.text_delta == "abcdef" - finally: - await buf.close() - - -class TestCoalescingBufferClose: - @pytest.mark.asyncio - async def test_close_drains_remaining_buffered_items(self, task_message: TaskMessage) -> None: - """Items added after the last timer tick must still flush before close() - completes โ€” the persisted message body and the stream contract both - require it.""" - flushed: list[StreamTaskMessageDelta] = [] - - async def on_flush(u: StreamTaskMessageDelta) -> None: - flushed.append(u) - - buf = CoalescingBuffer(on_flush=on_flush) - buf.start() - await buf.add(_text(task_message, "first")) # immediate - await asyncio.sleep(0.020) - flushed.clear() - - # Add an item and immediately close โ€” too fast for the 50ms timer. - await buf.add(_text(task_message, "last")) - await buf.close() - - assert len(flushed) == 1 - assert flushed[0].delta is not None and isinstance(flushed[0].delta, TextDelta) - assert flushed[0].delta.text_delta == "last" - - @pytest.mark.asyncio - async def test_close_when_idle_is_safe(self, task_message: TaskMessage) -> None: - """``close()`` with no buffered items must not raise.""" - buf = CoalescingBuffer(on_flush=AsyncMock()) - buf.start() - await buf.close() # no items, no signal, just exit cleanly - - @pytest.mark.asyncio - async def test_add_after_close_is_noop(self, task_message: TaskMessage) -> None: - """Defensive: ``add`` after ``close`` must silently do nothing rather - than raise. Real flows shouldn't hit this but tests racing close() - should not blow up.""" - flushed: list[StreamTaskMessageDelta] = [] - - async def on_flush(u: StreamTaskMessageDelta) -> None: - flushed.append(u) - - buf = CoalescingBuffer(on_flush=on_flush) - buf.start() - await buf.close() - # Fully drained and closed; this should silently no-op. - await buf.add(_text(task_message, "after")) - assert flushed == [] - - @pytest.mark.asyncio - async def test_add_racing_close_is_not_stranded(self, task_message: TaskMessage) -> None: - """TOCTOU: a delta that passes add()'s pre-lock _closed check but only - acquires the lock after close() set _closed must be dropped, not appended - to a drained, ticker-less buffer where it would never be published.""" - buf = CoalescingBuffer(on_flush=AsyncMock()) - buf.start() - # Hold the lock so add() parks *after* its pre-lock _closed check. - await buf._lock.acquire() - add_task = asyncio.create_task(buf.add(_text(task_message, "racing"))) - await asyncio.sleep(0) # add() passes the _closed check, blocks on the lock - buf._closed = True # close() wins the race - buf._lock.release() - await add_task - - assert buf._buf == [], "racing delta was stranded in the closed buffer" - await buf.close() # cleanup - - -class TestCoalescingBufferCloseDuringFlush: - @pytest.mark.asyncio - async def test_close_during_flush_is_exactly_once( - self, task_message: TaskMessage - ) -> None: - """Regression: ``close()`` while the ticker is mid-flush must publish - each delta exactly once โ€” no loss, no duplicate. - - The earlier implementation cancelled the ticker task during ``close()`` - and re-enqueued the in-flight item to avoid silent loss; that produced - a duplicated tail on the Redis stream when the Redis write had in fact - completed before the cancellation landed. The current implementation - signals the ticker to exit naturally after its next drain pass, which - gives exactly-once delivery without the duplication. - """ - flushed: list[StreamTaskMessageDelta] = [] - first_started = asyncio.Event() - first_continue = asyncio.Event() - - async def slow_flush(u: StreamTaskMessageDelta) -> None: - flushed.append(u) - if len(flushed) == 1: - first_started.set() - # Block the first publish until the test releases it; this - # parks close() inside the ticker's flush loop. - await first_continue.wait() - - buf = CoalescingBuffer(on_flush=slow_flush) - buf.start() - # Add five items quickly; they all land in self._buf and the ticker - # will drain them as one merged batch. - for i in range(5): - await buf.add(_text(task_message, f"chunk{i}")) - - await asyncio.wait_for(first_started.wait(), timeout=2.0) - # Trigger close() while the first flush is blocked, then release it. - close_task = asyncio.create_task(buf.close()) - # Give close() a tick to set _closed and start awaiting the ticker. - await asyncio.sleep(0) - first_continue.set() - await close_task - - full = "".join( - u.delta.text_delta or "" - for u in flushed - if isinstance(u.delta, TextDelta) - ) - # Exactly the five chunks, in order, with no duplication of any - # chunk's tail. - assert full == "chunk0chunk1chunk2chunk3chunk4", ( - f"expected exactly-once delivery; got: {full!r} " - f"(payloads: {[u.delta.text_delta for u in flushed if isinstance(u.delta, TextDelta)]})" - ) - - -class TestStreamingTaskMessageContextModes: - @pytest.mark.asyncio - async def test_off_mode_skips_publishes_but_persists_full_content(self) -> None: - ctx, svc, tm = await _make_context("off") - svc.stream_update.reset_mock() - for chunk in ("Hello", " ", "world"): - await ctx.stream_update(_text(tm, chunk)) - # Plenty of time for any background ticker โ€” none should exist. - await asyncio.sleep(0.080) - assert svc.stream_update.call_count == 0, "off mode must publish zero per-delta updates" - - await ctx.close() - # The persisted message body must still contain the full assembled text, - # because the accumulator was fed even when publishing was suppressed. - update_kwargs = ctx._agentex_client.messages.update.call_args.kwargs - assert update_kwargs["content"]["content"] == "Hello world" - - @pytest.mark.asyncio - async def test_per_token_mode_publishes_each_delta_immediately(self) -> None: - ctx, svc, tm = await _make_context("per_token") - svc.stream_update.reset_mock() - for chunk in ("a", "b", "c"): - await ctx.stream_update(_text(tm, chunk)) - # Per-token mode must publish synchronously, no waiting required. - assert svc.stream_update.call_count == 3 - await ctx.close() - - @pytest.mark.asyncio - async def test_coalesced_mode_batches_and_persists_full_content(self) -> None: - ctx, svc, tm = await _make_context("coalesced") - svc.stream_update.reset_mock() - for chunk in ("Hello", " ", "world", "!"): - await ctx.stream_update(_text(tm, chunk)) - await ctx.close() - - # Assembled content is the union of all per-delta text. - update_kwargs = ctx._agentex_client.messages.update.call_args.kwargs - assert update_kwargs["content"]["content"] == "Hello world!" - - # Coalesced mode produces fewer publishes than per_token (4) but at - # least the start + at least one delta + done. - delta_publishes = [ - call - for call in svc.stream_update.call_args_list - if isinstance(call.args[0] if call.args else None, StreamTaskMessageDelta) - ] - assert len(delta_publishes) >= 1, "coalesced mode should publish at least one delta" - assert len(delta_publishes) < 4, "coalesced mode should batch at least some of the four chunks" - - -class TestStreamingTaskMessageContextCreatedAt: - """Verifies the workflow-supplied created_at is forwarded to messages.create - on open(), and omitted (server default) when no timestamp is supplied.""" - - @pytest.mark.asyncio - async def test_open_forwards_created_at(self) -> None: - from datetime import datetime, timezone - - from agentex._types import omit - - ts = datetime(2026, 5, 13, 18, 30, 0, tzinfo=timezone.utc) - tm = TaskMessage( - id="m1", - task_id="t1", - content=TextContent(author="agent", content="", format="markdown"), - streaming_status="IN_PROGRESS", - ) - svc = MagicMock() - svc.stream_update = AsyncMock() - client = MagicMock() - client.messages.create = AsyncMock(return_value=tm) - client.messages.update = AsyncMock() - ctx = StreamingTaskMessageContext( - task_id="t1", - initial_content=TextContent(author="agent", content="", format="markdown"), - agentex_client=client, - streaming_service=svc, - streaming_mode="off", - created_at=ts, - ) - await ctx.open() - - kwargs = client.messages.create.call_args.kwargs - assert kwargs["created_at"] == ts - assert kwargs["created_at"] is not omit - - @pytest.mark.asyncio - async def test_open_without_created_at_passes_omit(self) -> None: - from agentex._types import omit - - tm = TaskMessage( - id="m1", - task_id="t1", - content=TextContent(author="agent", content="", format="markdown"), - streaming_status="IN_PROGRESS", - ) - svc = MagicMock() - svc.stream_update = AsyncMock() - client = MagicMock() - client.messages.create = AsyncMock(return_value=tm) - client.messages.update = AsyncMock() - ctx = StreamingTaskMessageContext( - task_id="t1", - initial_content=TextContent(author="agent", content="", format="markdown"), - agentex_client=client, - streaming_service=svc, - streaming_mode="off", - ) - await ctx.open() - - kwargs = client.messages.create.call_args.kwargs - assert kwargs["created_at"] is omit - - -class TestFullMessageClosesBuffer: - """A StreamTaskMessageFull must stop the buffer ticker and drain its deltas - before the terminal Full. Marking the context done without closing the - buffer leaves close()'s _is_closed short-circuit to orphan the ticker, and - publishing buffered deltas after the Full reads as a stale duplicate tail.""" - - @pytest.mark.asyncio - async def test_full_message_stops_ticker(self) -> None: - ctx, _svc, tm = await _make_context("coalesced") - # A delta makes the buffer and its ticker live. - await ctx.stream_update(_text(tm, "hello")) - buf = ctx._buffer - assert buf is not None - task = buf._task - assert task is not None and not task.done() - - await ctx.stream_update( - StreamTaskMessageFull( - parent_task_message=tm, - content=TextContent(author="agent", content="final", format="markdown"), - type="full", - ) - ) - - assert ctx._buffer is None, "Full message left the buffer un-closed" - assert task.done(), "coalescing-buffer ticker still running after Full (orphaned)" - - @pytest.mark.asyncio - async def test_full_is_terminal_publish_no_trailing_deltas(self) -> None: - # Buffered deltas must publish BEFORE the Full, never after (a trailing - # delta after the terminal Full reads as a stale duplicate tail). - ctx, svc, tm = await _make_context("coalesced") - # Two deltas through the buffer. Regardless of how the coalescing window - # batches them (1 or 2 publishes), the invariant under test is the same: - # every delta publishes before the terminal Full, never after it. - await ctx.stream_update(_text(tm, "alpha")) - await ctx.stream_update(_text(tm, "beta")) - - full = StreamTaskMessageFull( - parent_task_message=tm, - content=TextContent(author="agent", content="alphabeta", format="markdown"), - type="full", - ) - await ctx.stream_update(full) - - published = [c.args[0] for c in svc.stream_update.await_args_list] - assert published, "nothing was published" - assert published[-1] is full, ( - f"Full must be the terminal publish; saw trailing " - f"{type(published[-1]).__name__} after it (stale duplicate tail)" - ) - assert any(isinstance(u, StreamTaskMessageDelta) for u in published[:-1]), ( - "expected the buffered deltas to be published before the Full" - ) diff --git a/tests/lib/core/temporal/__init__.py b/tests/lib/core/temporal/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/lib/core/temporal/plugins/__init__.py b/tests/lib/core/temporal/plugins/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/lib/core/temporal/plugins/openai_agents/__init__.py b/tests/lib/core/temporal/plugins/openai_agents/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/lib/core/temporal/plugins/openai_agents/test_model_usage.py b/tests/lib/core/temporal/plugins/openai_agents/test_model_usage.py deleted file mode 100644 index bf3dc8006..000000000 --- a/tests/lib/core/temporal/plugins/openai_agents/test_model_usage.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Tests that the openai_agents streaming model copies real token usage onto spans. - -The backend bills per-call usage from ``span.output["usage"]``; these tests -assert the streaming model writes the API-reported usage there (and into the -returned ``ModelResponse.usage``) instead of dropping it. -""" - -from __future__ import annotations - -from typing import Any -from datetime import UTC, datetime -from contextlib import asynccontextmanager -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from agents import ModelSettings -from openai.types.responses import Response, ResponseCompletedEvent -from openai.types.responses.response_usage import ( - ResponseUsage, - InputTokensDetails, - OutputTokensDetails, -) - -import agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model as tsm -from agentex.types.span import Span -from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ( - streaming_task_id, - streaming_trace_id, - streaming_parent_span_id, -) - -pytestmark = pytest.mark.asyncio - - -class FakeTrace: - """Captures spans handed out by trace.span() so tests can inspect them.""" - - def __init__(self) -> None: - self.spans: list[Span] = [] - - @asynccontextmanager - async def span(self, name, parent_id=None, input=None, data=None, task_id=None): - span = Span( - id=f"span-{len(self.spans)}", - name=name, - start_time=datetime.now(UTC), - trace_id="trace-1", - parent_id=parent_id, - input=input, - data=data, - task_id=task_id, - ) - self.spans.append(span) - yield span - - -class FakeTracer: - def __init__(self) -> None: - self.trace_obj = FakeTrace() - - def trace(self, trace_id): - return self.trace_obj - - -@pytest.fixture -def tracing_contextvars(): - tokens = [ - streaming_task_id.set("task-1"), - streaming_trace_id.set("trace-1"), - streaming_parent_span_id.set("parent-span-1"), - ] - yield - streaming_task_id.reset(tokens[0]) - streaming_trace_id.reset(tokens[1]) - streaming_parent_span_id.reset(tokens[2]) - - -EXPECTED_USAGE_BLOB = { - "input_tokens": 120, - "output_tokens": 80, - "total_tokens": 200, - "cached_input_tokens": 30, - "reasoning_tokens": 40, -} - - -def _output_dict(span: Span) -> dict[str, Any]: - assert isinstance(span.output, dict) - return span.output - - -class FakeStream: - def __init__(self, events) -> None: - self._events = events - - def __aiter__(self): - async def gen(): - for event in self._events: - yield event - - return gen() - - -class TestTemporalStreamingModel: - async def test_streaming_model_captures_final_response_usage(self, tracing_contextvars): - usage = ResponseUsage( - input_tokens=120, - output_tokens=80, - total_tokens=200, - input_tokens_details=InputTokensDetails(cached_tokens=30), - output_tokens_details=OutputTokensDetails(reasoning_tokens=40), - ) - completed = ResponseCompletedEvent.model_construct( - type="response.completed", - response=Response.model_construct(output=[], usage=usage), - ) - - fake_tracer = FakeTracer() - openai_client = MagicMock() - openai_client.responses.create = AsyncMock(return_value=FakeStream([completed])) - - with patch.object(tsm, "create_async_agentex_client", return_value=MagicMock()): - with patch.object(tsm, "AsyncTracer", return_value=fake_tracer): - model = tsm.TemporalStreamingModel(model_name="gpt-4o", openai_client=openai_client) - - response = await model.get_response( - system_instructions=None, - input="hello", - model_settings=ModelSettings(), - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - # Real usage lands on the returned ModelResponse (was zeroed before) - assert response.usage.input_tokens == 120 - assert response.usage.output_tokens == 80 - assert response.usage.total_tokens == 200 - assert response.usage.input_tokens_details.cached_tokens == 30 - assert response.usage.output_tokens_details.reasoning_tokens == 40 - - # And on the span output for billing - assert len(fake_tracer.trace_obj.spans) == 1 - span = fake_tracer.trace_obj.spans[0] - assert _output_dict(span)["usage"] == EXPECTED_USAGE_BLOB - - async def test_streaming_model_writes_zero_usage_when_api_reports_none(self, tracing_contextvars): - completed = ResponseCompletedEvent.model_construct( - type="response.completed", - response=Response.model_construct(output=[], usage=None), - ) - - fake_tracer = FakeTracer() - openai_client = MagicMock() - openai_client.responses.create = AsyncMock(return_value=FakeStream([completed])) - - with patch.object(tsm, "create_async_agentex_client", return_value=MagicMock()): - with patch.object(tsm, "AsyncTracer", return_value=fake_tracer): - model = tsm.TemporalStreamingModel(model_name="gpt-4o", openai_client=openai_client) - - response = await model.get_response( - system_instructions=None, - input="hello", - model_settings=ModelSettings(), - tools=[], - output_schema=None, - handoffs=[], - tracing=None, - ) - - # No usage from the API: the model reports zeros rather than omitting, - # so billing sums 0 instead of missing the span - assert response.usage.input_tokens == 0 - span = fake_tracer.trace_obj.spans[0] - assert _output_dict(span)["usage"] == { - "input_tokens": 0, - "output_tokens": 0, - "total_tokens": 0, - "cached_input_tokens": 0, - "reasoning_tokens": 0, - } diff --git a/tests/lib/core/temporal/test_base_workflow_continue_as_new.py b/tests/lib/core/temporal/test_base_workflow_continue_as_new.py deleted file mode 100644 index bc899c41b..000000000 --- a/tests/lib/core/temporal/test_base_workflow_continue_as_new.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Unit tests for BaseWorkflow's continue-as-new lifecycle helpers. - -These exercise the pure decision helpers (``should_continue_as_new`` and -``is_continued_run``) by faking ``workflow.info()`` so we don't need a running -Temporal server. The drain + ``workflow.continue_as_new`` mechanics in -``drain_and_continue_as_new`` / ``run_until_complete`` are best covered by a -replay/integration test against a Temporal test environment (a follow-up). -""" - -from __future__ import annotations - -from typing import override - -import pytest - -from agentex.lib.core.temporal.workflows import workflow as base_workflow_module -from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow - - -class _ConcreteWorkflow(BaseWorkflow): - """Minimal concrete subclass so we can instantiate the ABC in a test.""" - - def __init__(self) -> None: - self.display_name = "test" - - @override - async def on_task_event_send(self, params) -> None: # pragma: no cover - unused - raise NotImplementedError - - @override - async def on_task_create(self, params) -> None: # pragma: no cover - unused - raise NotImplementedError - - -class _FakeInfo: - def __init__(self, *, suggested: bool, continued_run_id: str | None = None) -> None: - self._suggested = suggested - self.continued_run_id = continued_run_id - - def is_continue_as_new_suggested(self) -> bool: - return self._suggested - - -@pytest.fixture -def patch_info(monkeypatch): - """Patch ``workflow.info`` used inside the BaseWorkflow module.""" - - def _apply(*, suggested: bool = False, continued_run_id: str | None = None) -> None: - monkeypatch.setattr( - base_workflow_module.workflow, - "info", - lambda: _FakeInfo(suggested=suggested, continued_run_id=continued_run_id), - ) - - return _apply - - -def test_recycles_when_temporal_suggests(patch_info): - patch_info(suggested=True) - assert _ConcreteWorkflow().should_continue_as_new() is True - - -def test_no_recycle_when_not_suggested(patch_info): - patch_info(suggested=False) - assert _ConcreteWorkflow().should_continue_as_new() is False - - -def test_is_continued_run_false_on_original_run(patch_info): - patch_info(continued_run_id=None) - assert _ConcreteWorkflow().is_continued_run() is False - - -def test_is_continued_run_true_after_recycle(patch_info): - patch_info(continued_run_id="run-123") - assert _ConcreteWorkflow().is_continued_run() is True diff --git a/tests/lib/core/temporal/workers/__init__.py b/tests/lib/core/temporal/workers/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/lib/core/temporal/workers/test_worker_version_guard.py b/tests/lib/core/temporal/workers/test_worker_version_guard.py deleted file mode 100644 index 4ab5fc435..000000000 --- a/tests/lib/core/temporal/workers/test_worker_version_guard.py +++ /dev/null @@ -1,70 +0,0 @@ -"""AgentexWorker wires the backend version guard into worker startup. - -A Temporal worker runs as its own process and never goes through the ACP server -lifespan, so the guard must run inside `_register_agent` โ€” before `register_agent`, -and only when `AGENTEX_BASE_URL` is set. -""" - -from __future__ import annotations - -from unittest.mock import Mock, AsyncMock - -import pytest - -from agentex.lib.core.temporal.workers import worker as worker_mod -from agentex.lib.core.compat.version_guard import IncompatibleBackendError - - -def _worker(): - # explicit health_check_port so __init__ doesn't read EnvironmentVariables - return worker_mod.AgentexWorker(task_queue="test-queue", health_check_port=8080) - - -def _patch_env(monkeypatch, base_url): - env = Mock() - env.AGENTEX_BASE_URL = base_url - fake_cls = Mock() - fake_cls.refresh.return_value = env - monkeypatch.setattr(worker_mod, "EnvironmentVariables", fake_cls) - return env - - -async def test_guard_runs_before_register_agent(monkeypatch): - env = _patch_env(monkeypatch, "http://backend") - order: list[str] = [] - guard = AsyncMock(side_effect=lambda *a, **k: order.append("guard")) - register = AsyncMock(side_effect=lambda *a, **k: order.append("register")) - monkeypatch.setattr(worker_mod, "assert_backend_compatible", guard) - monkeypatch.setattr(worker_mod, "register_agent", register) - - await _worker()._register_agent() - - guard.assert_awaited_once_with("http://backend") - register.assert_awaited_once_with(env) - assert order == ["guard", "register"] # guard must precede registration - - -async def test_incompatible_backend_blocks_registration(monkeypatch): - _patch_env(monkeypatch, "http://backend") - guard = AsyncMock(side_effect=IncompatibleBackendError("backend too old")) - register = AsyncMock() - monkeypatch.setattr(worker_mod, "assert_backend_compatible", guard) - monkeypatch.setattr(worker_mod, "register_agent", register) - - with pytest.raises(IncompatibleBackendError): - await _worker()._register_agent() - - register.assert_not_awaited() # fail fast โ€” never register against an unsupported backend - - -async def test_no_base_url_skips_guard_and_registration(monkeypatch): - _patch_env(monkeypatch, None) - guard = AsyncMock() - register = AsyncMock() - monkeypatch.setattr(worker_mod, "assert_backend_compatible", guard) - monkeypatch.setattr(worker_mod, "register_agent", register) - - await _worker()._register_agent() - - guard.assert_not_awaited() - register.assert_not_awaited() diff --git a/tests/lib/core/tracing/__init__.py b/tests/lib/core/tracing/__init__.py 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 84f37b495..000000000 --- a/tests/lib/core/tracing/processors/test_agentex_tracing_processor.py +++ /dev/null @@ -1,285 +0,0 @@ -from __future__ import annotations - -import asyncio -import weakref -from datetime import datetime, timezone -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -# AgentexAsyncTracingProcessor pulls in agentex.lib.adk via -# create_async_agentex_client, which in turn imports pydantic_ai at package -# init. Skip these tests cleanly when pydantic_ai isn't installed (the SDK -# dev venv state) so collection doesn't error out. -pytest.importorskip( - "pydantic_ai", - reason="agentex.lib.adk import chain requires pydantic_ai", -) - -# Import the processor module up front so unittest.mock.patch() can resolve -# attributes by string path. The tracing_processor_manager only loads this -# module lazily, so without this explicit import the patches below would fail -# with AttributeError at __enter__ time. -import agentex.lib.core.tracing.processors.agentex_tracing_processor # noqa: E402, F401 - -MODULE = "agentex.lib.core.tracing.processors.agentex_tracing_processor" - - -SKIP_ENV = "AGENTEX_TRACING_SKIP_AGENTEX_SPAN_START" - - -def _make_config() -> MagicMock: - """Empty config โ€” AgentexTracingProcessorConfig is unused by __init__.""" - return MagicMock() - - -def _make_span(): - from agentex.types.span import Span - - now = datetime.now(timezone.utc) - return Span( - id="span-1", - trace_id="trace-1", - name="test-span", - start_time=now, - end_time=now, - input={"in": 1}, - output={"out": 2}, - ) - - -class TestAgentexSyncSkipSpanStart: - """The Agentex backend writes create-on-start + update-on-end by default. - End-only ingest (default) skips the start write and makes the END a single - create โ€” verify the start is a no-op and end does an INSERT, not an UPDATE. - """ - - def test_start_skipped_and_end_creates_by_default(self, monkeypatch): - monkeypatch.delenv(SKIP_ENV, raising=False) # default ON - with patch(f"{MODULE}.Agentex") as MockAgentex: - from agentex.lib.core.tracing.processors.agentex_tracing_processor import ( - AgentexSyncTracingProcessor, - ) - - processor = AgentexSyncTracingProcessor(_make_config()) - client = MockAgentex.return_value - span = _make_span() - - processor.on_span_start(span) - client.spans.create.assert_not_called() # start skipped - client.spans.update.assert_not_called() - - processor.on_span_end(span) - client.spans.create.assert_called_once() # single INSERT on end - client.spans.update.assert_not_called() # never a 404-prone UPDATE - - def test_start_creates_and_end_updates_when_skip_disabled(self, monkeypatch): - monkeypatch.setenv(SKIP_ENV, "0") - with patch(f"{MODULE}.Agentex") as MockAgentex: - from agentex.lib.core.tracing.processors.agentex_tracing_processor import ( - AgentexSyncTracingProcessor, - ) - - processor = AgentexSyncTracingProcessor(_make_config()) - client = MockAgentex.return_value - span = _make_span() - - processor.on_span_start(span) - client.spans.create.assert_called_once() # start write restored - - processor.on_span_end(span) - client.spans.update.assert_called_once() # end is the UPDATE - - def test_skip_decision_captured_at_init_not_per_call(self, monkeypatch): - """The two halves of a span MUST use the same skip decision. A flag - toggled after construction must not split it (start-skip + end-update - would 404). The decision is captured once at init. - """ - monkeypatch.delenv(SKIP_ENV, raising=False) # construct with skip ON - with patch(f"{MODULE}.Agentex") as MockAgentex: - from agentex.lib.core.tracing.processors.agentex_tracing_processor import ( - AgentexSyncTracingProcessor, - ) - - processor = AgentexSyncTracingProcessor(_make_config()) - client = MockAgentex.return_value - span = _make_span() - - processor.on_span_start(span) # skipped (cached ON) - monkeypatch.setenv(SKIP_ENV, "0") # toggle mid-span โ€” must be ignored - processor.on_span_end(span) - - client.spans.create.assert_called_once() # still end-only INSERT - client.spans.update.assert_not_called() # NOT a 404-prone UPDATE - - -class TestAgentexAsyncSkipSpanStart: - async def test_start_skipped_and_end_creates_by_default(self, monkeypatch): - monkeypatch.delenv(SKIP_ENV, raising=False) # default ON - with patch(f"{MODULE}.create_async_agentex_client") as mock_factory: - client = MagicMock() - client.spans.create = AsyncMock() - client.spans.update = AsyncMock() - mock_factory.return_value = client - - from agentex.lib.core.tracing.processors.agentex_tracing_processor import ( - AgentexAsyncTracingProcessor, - ) - - processor = AgentexAsyncTracingProcessor(_make_config()) - span = _make_span() - - await processor.on_span_start(span) - client.spans.create.assert_not_called() # start skipped - client.spans.update.assert_not_called() - - await processor.on_span_end(span) - client.spans.create.assert_awaited_once() # single INSERT on end - client.spans.update.assert_not_called() - - async def test_start_creates_and_end_updates_when_skip_disabled(self, monkeypatch): - monkeypatch.setenv(SKIP_ENV, "0") - with patch(f"{MODULE}.create_async_agentex_client") as mock_factory: - client = MagicMock() - client.spans.create = AsyncMock() - client.spans.update = AsyncMock() - mock_factory.return_value = client - - from agentex.lib.core.tracing.processors.agentex_tracing_processor import ( - AgentexAsyncTracingProcessor, - ) - - processor = AgentexAsyncTracingProcessor(_make_config()) - span = _make_span() - - await processor.on_span_start(span) - client.spans.create.assert_awaited_once() # start write restored - - await processor.on_span_end(span) - client.spans.update.assert_awaited_once() # end is the UPDATE - - async def test_skip_decision_captured_at_init_not_per_call(self, monkeypatch): - """A flag toggled after construction must not split a span's lifecycle.""" - monkeypatch.delenv(SKIP_ENV, raising=False) # construct with skip ON - with patch(f"{MODULE}.create_async_agentex_client") as mock_factory: - client = MagicMock() - client.spans.create = AsyncMock() - client.spans.update = AsyncMock() - mock_factory.return_value = client - - from agentex.lib.core.tracing.processors.agentex_tracing_processor import ( - AgentexAsyncTracingProcessor, - ) - - processor = AgentexAsyncTracingProcessor(_make_config()) - span = _make_span() - - await processor.on_span_start(span) # skipped (cached ON) - monkeypatch.setenv(SKIP_ENV, "0") # toggle mid-span โ€” must be ignored - await processor.on_span_end(span) - - client.spans.create.assert_awaited_once() # still end-only INSERT - client.spans.update.assert_not_called() # NOT a 404-prone UPDATE - - -class TestAgentexAsyncTracingProcessor: - """Coverage for the per-event-loop client cache. The SGP processor has - matching tests; mirror them here so a regression in the Agentex side - (e.g. an accidental refactor that switches back to a plain dict, or - drops the lazy lookup) does not slip through unnoticed. - """ - - async def test_client_caches_per_event_loop(self): - """First access builds the client; subsequent accesses in the same - running loop must return the cached instance. - """ - with patch(f"{MODULE}.create_async_agentex_client") as mock_factory: - mock_factory.side_effect = lambda **kwargs: MagicMock() - - from agentex.lib.core.tracing.processors.agentex_tracing_processor import ( - AgentexAsyncTracingProcessor, - ) - - processor = AgentexAsyncTracingProcessor(_make_config()) - - # Construction must not eagerly build the client (no running loop - # guarantee at module import time). - assert mock_factory.call_count == 0 - - c1 = processor.client - c2 = processor.client - c3 = processor.client - - assert mock_factory.call_count == 1, ( - f"Expected client to be built once per loop, but " - f"create_async_agentex_client was called {mock_factory.call_count} times" - ) - assert c1 is c2 is c3 - - async def test_client_keepalive_is_enabled(self): - """Regression guard: the per-loop client must use keepalive โ€” the - whole reason for the per-loop cache. Verify max_keepalive_connections > 0. - """ - import httpx as _httpx - - captured_limits: list[_httpx.Limits] = [] - original_async_client = _httpx.AsyncClient - - def capture_limits(*args, **kwargs): - limits = kwargs.get("limits") - if limits is not None: - captured_limits.append(limits) - return original_async_client(*args, **kwargs) - - with patch(f"{MODULE}.create_async_agentex_client") as mock_factory, patch( - "httpx.AsyncClient", side_effect=capture_limits - ): - mock_factory.side_effect = lambda **kwargs: MagicMock() - - from agentex.lib.core.tracing.processors.agentex_tracing_processor import ( - AgentexAsyncTracingProcessor, - ) - - processor = AgentexAsyncTracingProcessor(_make_config()) - _ = processor.client - - assert len(captured_limits) == 1 - max_keepalive = captured_limits[0].max_keepalive_connections - assert max_keepalive is not None and max_keepalive > 0, ( - f"Agentex async client should have keepalive enabled, got " - f"max_keepalive_connections={max_keepalive}" - ) - - def test_cache_is_weakkeydict_and_evicts_dead_loops(self): - """Regression guard for the id()-reuse bug: the per-loop cache must - be a WeakKeyDictionary so a GC'd loop's entry is evicted. Otherwise - a new loop landing at the same memory address would reuse the dead - loop's client, reintroducing the "bound to a different event loop" - error the per-loop cache was built to prevent. - """ - import gc - - with patch(f"{MODULE}.create_async_agentex_client"): - from agentex.lib.core.tracing.processors.agentex_tracing_processor import ( - AgentexAsyncTracingProcessor, - ) - - processor = AgentexAsyncTracingProcessor(_make_config()) - - # Storage type itself: WeakKeyDictionary, not plain dict. - assert isinstance(processor._clients_by_loop, weakref.WeakKeyDictionary) - - # End-to-end check: insert under a loop, drop the loop, the entry - # must vanish after GC. - loop = asyncio.new_event_loop() - try: - processor._clients_by_loop[loop] = MagicMock() - assert len(processor._clients_by_loop) == 1 - finally: - loop.close() - del loop - gc.collect() - assert len(processor._clients_by_loop) == 0, ( - "WeakKeyDictionary should have evicted the dead loop's entry; " - "remaining keys would cause stale-client reuse on id() recycling." - ) diff --git a/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py b/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py deleted file mode 100644 index dc8bab127..000000000 --- a/tests/lib/core/tracing/processors/test_sgp_tracing_processor.py +++ /dev/null @@ -1,496 +0,0 @@ -from __future__ import annotations - -import uuid -import asyncio -from datetime import UTC, datetime -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from agentex.types.span import Span -from agentex.lib.types.tracing import SGPTracingProcessorConfig - -MODULE = "agentex.lib.core.tracing.processors.sgp_tracing_processor" - - -def _make_config() -> SGPTracingProcessorConfig: - return SGPTracingProcessorConfig( - sgp_api_key="test-key", - sgp_account_id="test-account", - ) - - -def _make_span(span_id: str | None = None) -> Span: - return Span( - id=span_id or str(uuid.uuid4()), - name="test-span", - start_time=datetime.now(UTC), - trace_id="trace-1", - ) - - -def _make_mock_sgp_span() -> MagicMock: - sgp_span = MagicMock() - sgp_span.to_request_params.return_value = {"mock": "params"} - sgp_span.start_time = None - sgp_span.end_time = None - sgp_span.output = None - sgp_span.metadata = None - return sgp_span - - -# --------------------------------------------------------------------------- -# 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, monkeypatch): - """With start writes enabled, each span produces one flush on start and one on end.""" - monkeypatch.setenv("AGENTEX_TRACING_SKIP_SPAN_START", "0") - processor, _ = self._make_processor() - - with patch(f"{MODULE}.create_span", side_effect=lambda **kw: _make_mock_sgp_span()) as mock_cs: - for _ in range(100): - span = _make_span() - processor.on_span_start(span) - span.end_time = datetime.now(UTC) - processor.on_span_end(span) - - # 100 spans ร— (1 start + 1 end) = 200 build calls. - assert mock_cs.call_count == 200 - - def test_span_end_without_prior_start_still_flushes(self): - """Cross-pod Temporal case: END activity lands on a pod that never saw START. - - Today this used to be a silent no-op. After the stateless refactor it - must still flush a complete span (start_time + end_time + payload). - """ - processor, _ = self._make_processor() - - captured_spans: list[MagicMock] = [] - - def capture_create_span(**kwargs): - sgp_span = _make_mock_sgp_span() - captured_spans.append(sgp_span) - return sgp_span - - with patch(f"{MODULE}.create_span", side_effect=capture_create_span): - span = _make_span() - span.end_time = datetime.now(UTC) - # No on_span_start โ€” END lands here for the first time. - processor.on_span_end(span) - - assert len(captured_spans) == 1 - assert captured_spans[0].flush.called - assert captured_spans[0].start_time is not None - assert captured_spans[0].end_time is not None - - def test_span_start_skipped_by_default(self, monkeypatch): - """Default (end-only): on_span_start is a no-op; only on_span_end writes.""" - monkeypatch.delenv("AGENTEX_TRACING_SKIP_SPAN_START", raising=False) - processor, _ = self._make_processor() - - with patch(f"{MODULE}.create_span", side_effect=lambda **kw: _make_mock_sgp_span()) as mock_cs: - span = _make_span() - processor.on_span_start(span) - assert mock_cs.call_count == 0 # start skipped โ€” nothing built or flushed - span.end_time = datetime.now(UTC) - processor.on_span_end(span) - - assert mock_cs.call_count == 1 # only the end write - - def test_span_start_emitted_when_skip_disabled(self, monkeypatch): - """With skip disabled, on_span_start builds and flushes a span.""" - monkeypatch.setenv("AGENTEX_TRACING_SKIP_SPAN_START", "0") - processor, _ = self._make_processor() - - captured: list[MagicMock] = [] - - def capture(**kwargs): - sgp_span = _make_mock_sgp_span() - captured.append(sgp_span) - return sgp_span - - with patch(f"{MODULE}.create_span", side_effect=capture): - processor.on_span_start(_make_span()) - - assert len(captured) == 1 - assert captured[0].flush.called - - -# --------------------------------------------------------------------------- -# Async processor tests -# --------------------------------------------------------------------------- - - -class TestSGPAsyncTracingProcessor: - @staticmethod - def _make_processor(): - mock_env = MagicMock() - mock_env.refresh.return_value = MagicMock(ACP_TYPE=None, AGENT_NAME=None, AGENT_ID=None) - mock_create_span = MagicMock(side_effect=lambda **kwargs: _make_mock_sgp_span()) - - mock_async_client = MagicMock() - mock_async_client.spans.upsert_batch = AsyncMock() - - with patch(f"{MODULE}.EnvironmentVariables", mock_env), patch(f"{MODULE}.create_span", mock_create_span), patch( - f"{MODULE}.AsyncSGPClient", return_value=mock_async_client - ): - from agentex.lib.core.tracing.processors.sgp_tracing_processor import ( - SGPAsyncTracingProcessor, - ) - - processor = SGPAsyncTracingProcessor(_make_config()) - - # Force the per-loop cache to return the mock for whatever loop the - # test runs on, by stubbing _get_client directly. - processor._get_client = lambda: mock_async_client # type: ignore[method-assign] - - return processor, mock_create_span, mock_async_client - - def test_processor_holds_no_per_span_state(self): - """Stateless processor must not retain any per-span dict between lifecycle events.""" - processor, _, _ = self._make_processor() - assert not hasattr(processor, "_spans") - - async def test_span_lifecycle_produces_two_upserts(self, monkeypatch): - """With start writes enabled, each span produces one upsert on start and one on end.""" - monkeypatch.setenv("AGENTEX_TRACING_SKIP_SPAN_START", "0") - processor, _, mock_client = self._make_processor() - - with patch(f"{MODULE}.create_span", side_effect=lambda **kw: _make_mock_sgp_span()): - span = _make_span() - await processor.on_span_start(span) - span.end_time = datetime.now(UTC) - await processor.on_span_end(span) - - assert mock_client.spans.upsert_batch.call_count == 2 - - async def test_spans_start_skipped_by_default(self, monkeypatch): - """Default (end-only): on_spans_start makes no upsert; on_spans_end does.""" - monkeypatch.delenv("AGENTEX_TRACING_SKIP_SPAN_START", raising=False) - processor, _, mock_client = self._make_processor() - - with patch(f"{MODULE}.create_span", side_effect=lambda **kw: _make_mock_sgp_span()): - spans = [_make_span() for _ in range(3)] - await processor.on_spans_start(spans) - assert mock_client.spans.upsert_batch.call_count == 0 # start skipped - for s in spans: - s.end_time = datetime.now(UTC) - await processor.on_spans_end(spans) - - assert mock_client.spans.upsert_batch.call_count == 1 # only the end write - - async def test_spans_start_emitted_when_skip_disabled(self, monkeypatch): - """With skip disabled, on_spans_start makes one upsert_batch call.""" - monkeypatch.setenv("AGENTEX_TRACING_SKIP_SPAN_START", "0") - processor, _, mock_client = self._make_processor() - - with patch(f"{MODULE}.create_span", side_effect=lambda **kw: _make_mock_sgp_span()): - await processor.on_spans_start([_make_span()]) - - assert mock_client.spans.upsert_batch.call_count == 1 - - async def test_span_end_without_prior_start_still_upserts(self): - """Cross-pod Temporal case: END activity lands on a pod that never saw START. - - Today this used to be a silent no-op. After the stateless refactor it - must still upsert a complete span via upsert_batch. - """ - processor, _, mock_client = self._make_processor() - - with patch(f"{MODULE}.create_span", side_effect=lambda **kw: _make_mock_sgp_span()): - span = _make_span() - span.end_time = datetime.now(UTC) - # No on_span_start โ€” END lands here for the first time. - await processor.on_span_end(span) - - assert mock_client.spans.upsert_batch.call_count == 1 - items = mock_client.spans.upsert_batch.call_args.kwargs["items"] - assert len(items) == 1 - - async def test_sgp_span_input_and_output_propagated_on_end(self, monkeypatch): - """on_span_end should send the span's current input and output via upsert_batch.""" - monkeypatch.setenv("AGENTEX_TRACING_SKIP_SPAN_START", "0") - processor, _, mock_client = self._make_processor() - - captured: list[MagicMock] = [] - - def capture_create_span(**kwargs): - sgp_span = _make_mock_sgp_span() - captured.append(sgp_span) - return sgp_span - - mock_create_span = MagicMock(side_effect=capture_create_span) - with patch(f"{MODULE}.create_span", mock_create_span): - span = _make_span() - span.input = {"messages": [{"role": "user", "content": "hello"}]} - await processor.on_span_start(span) - - span.input = { - "messages": [ - {"role": "user", "content": "hello"}, - {"role": "assistant", "content": "hi"}, - ] - } - span.output = {"response": "hi"} - span.end_time = datetime.now(UTC) - await processor.on_span_end(span) - - assert mock_client.spans.upsert_batch.call_count == 2 # start + end - # The end-time SGPSpan should have end_time populated. - end_span = captured[-1] - assert end_span.end_time is not None - # Verify the updated input/output reached create_span on the end call. - end_call_kwargs = mock_create_span.call_args_list[-1].kwargs - assert end_call_kwargs["input"]["messages"][-1]["role"] == "assistant" - assert end_call_kwargs["output"] == {"response": "hi"} - - async def test_on_spans_start_sends_single_upsert_for_batch(self, monkeypatch): - """Given N spans at once, on_spans_start should make ONE upsert_batch HTTP call.""" - monkeypatch.setenv("AGENTEX_TRACING_SKIP_SPAN_START", "0") - processor, _, mock_client = self._make_processor() - - n = 10 - spans = [_make_span() for _ in range(n)] - with patch(f"{MODULE}.create_span", side_effect=lambda **kw: _make_mock_sgp_span()): - await processor.on_spans_start(spans) - - assert mock_client.spans.upsert_batch.call_count == 1, ( - "Batched on_spans_start must make exactly one upsert_batch HTTP call" - ) - items = mock_client.spans.upsert_batch.call_args.kwargs["items"] - assert len(items) == n - - async def test_on_spans_start_records_export_success_metrics(self, monkeypatch): - monkeypatch.setenv("AGENTEX_TRACING_METRICS", "1") - monkeypatch.setenv("AGENTEX_TRACING_SKIP_SPAN_START", "0") - import agentex.lib.core.observability.tracing_metrics_recording as recording - - recording._metrics_enabled = None - recording._tracing = None - processor, _, mock_client = self._make_processor() - mock_metrics = MagicMock() - - n = 4 - spans = [_make_span() for _ in range(n)] - with patch(f"{MODULE}.create_span", side_effect=lambda **kw: _make_mock_sgp_span()), patch( - "agentex.lib.core.observability.tracing_metrics.get_tracing_metrics", - return_value=mock_metrics, - ): - await processor.on_spans_start(spans) - - mock_metrics.export_batches.add.assert_called_once_with( - 1, - {"processor": "sgp", "event_type": "start"}, - ) - mock_metrics.export_spans.add.assert_called_once_with( - n, - {"processor": "sgp", "event_type": "start"}, - ) - assert mock_client.spans.upsert_batch.call_count == 1 - - async def test_get_client_caches_per_event_loop(self): - """The processor must keep one client per event loop, and reuse it - across calls within the same loop. This is what enables connection - keepalive instead of paying a TLS handshake per span. - """ - mock_env = MagicMock() - mock_env.refresh.return_value = MagicMock(ACP_TYPE=None, AGENT_NAME=None, AGENT_ID=None) - - 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 - - -# --------------------------------------------------------------------------- -# AGENTEX_TRACING_SKIP_SPAN_START env parsing -# --------------------------------------------------------------------------- - - -class TestSkipSpanStartEnv: - @staticmethod - def _fn(): - from agentex.lib.core.tracing.processors.sgp_tracing_processor import ( - _skip_span_start_enabled, - ) - - return _skip_span_start_enabled - - def test_default_is_skip_enabled(self, monkeypatch): - """Unset โ†’ skip span-start (end-only ingest is the default).""" - monkeypatch.delenv("AGENTEX_TRACING_SKIP_SPAN_START", raising=False) - assert self._fn()() is True - - @pytest.mark.parametrize("val", ["0", "false", "no", "off", "FALSE", "Off", " no "]) - def test_falsy_values_restore_span_start(self, monkeypatch, val): - monkeypatch.setenv("AGENTEX_TRACING_SKIP_SPAN_START", val) - assert self._fn()() is False - - @pytest.mark.parametrize("val", ["1", "true", "yes", "on", "anything"]) - def test_other_values_keep_skip_enabled(self, monkeypatch, val): - monkeypatch.setenv("AGENTEX_TRACING_SKIP_SPAN_START", val) - assert self._fn()() is True diff --git a/tests/lib/core/tracing/processors/test_tracing_processor_interface.py b/tests/lib/core/tracing/processors/test_tracing_processor_interface.py 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_error.py b/tests/lib/core/tracing/test_span_error.py deleted file mode 100644 index 18116dcb3..000000000 --- a/tests/lib/core/tracing/test_span_error.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -import uuid -from typing import Any -from datetime import UTC, datetime -from unittest.mock import MagicMock, patch - -import pytest - -from agentex.types.span import Span -from agentex.lib.core.tracing.trace import Trace, AsyncTrace -from agentex.lib.core.tracing.span_error import ( - SPAN_ERROR_KEY, - get_span_error, - set_span_error, -) - -PROCESSOR_MODULE = "agentex.lib.core.tracing.processors.sgp_tracing_processor" - - -def _make_span(data=None) -> Span: - return Span( - id=str(uuid.uuid4()), - name="test-span", - start_time=datetime.now(UTC), - trace_id="trace-1", - data=data, - ) - - -# --------------------------------------------------------------------------- -# Helpers: set_span_error / get_span_error -# --------------------------------------------------------------------------- - - -class TestSpanErrorHelpers: - def test_set_then_get_on_none_data(self): - span = _make_span(data=None) - set_span_error(span, ValueError("boom")) - assert get_span_error(span) == {"type": "ValueError", "message": "boom"} - assert isinstance(span.data, dict) - assert span.data[SPAN_ERROR_KEY] == {"type": "ValueError", "message": "boom"} - - def test_set_preserves_existing_dict_keys(self): - span = _make_span(data={"__span_type__": "LLM"}) - set_span_error(span, RuntimeError("nope")) - assert isinstance(span.data, dict) - assert span.data["__span_type__"] == "LLM" - err = get_span_error(span) - assert err is not None - assert err["type"] == "RuntimeError" - - def test_get_returns_none_when_no_error(self): - assert get_span_error(_make_span(data={"foo": "bar"})) is None - assert get_span_error(_make_span(data=None)) is None - - def test_set_is_noop_on_list_data(self): - span = _make_span(data=[{"a": 1}]) - set_span_error(span, ValueError("boom")) - # list-shaped data is left untouched (mirrors _add_source_to_span) - assert span.data == [{"a": 1}] - assert get_span_error(span) is None - - -# --------------------------------------------------------------------------- -# Capture: the context managers record body exceptions onto the span -# --------------------------------------------------------------------------- - - -class TestContextManagerCapture: - def test_sync_span_records_error_and_reraises(self): - trace = Trace(processors=[], client=MagicMock(), trace_id="t1") - captured = {} - with pytest.raises(ValueError, match="boom"): - with trace.span("op") as span: - captured["span"] = span - raise ValueError("boom") - err = get_span_error(captured["span"]) - assert err == {"type": "ValueError", "message": "boom"} - - def test_sync_span_success_has_no_error(self): - trace = Trace(processors=[], client=MagicMock(), trace_id="t1") - with trace.span("op") as span: - pass - assert get_span_error(span) is None - - @pytest.mark.asyncio - async def test_async_span_records_error_and_reraises(self): - trace = AsyncTrace(processors=[], client=MagicMock(), trace_id="t1") - captured = {} - with pytest.raises(RuntimeError, match="kaboom"): - async with trace.span("op") as span: - captured["span"] = span - raise RuntimeError("kaboom") - err = get_span_error(captured["span"]) - assert err == {"type": "RuntimeError", "message": "kaboom"} - - -# --------------------------------------------------------------------------- -# Map: _build_sgp_span translates the recorded error into SGP status=ERROR -# --------------------------------------------------------------------------- - - -class _FakeSGPSpan: - def __init__(self, metadata: dict[str, Any] | None) -> None: - self.status = "SUCCESS" - self.metadata: dict[str, Any] = metadata if metadata is not None else {} - self.start_time = None - - def set_error( - self, - error_type: str | None = None, - error_message: str | None = None, - exception: BaseException | None = None, - ) -> None: - self.status = "ERROR" - self.metadata["error"] = True - self.metadata["error_type"] = error_type - self.metadata["error_message"] = error_message - - -def _fake_create_span(**kwargs: Any) -> _FakeSGPSpan: - return _FakeSGPSpan(kwargs.get("metadata")) - - -class TestBuildSGPSpanMapping: - @staticmethod - def _env(): - return MagicMock(ACP_TYPE=None, AGENT_NAME=None, AGENT_ID=None) - - def test_error_maps_to_status_error(self): - from agentex.lib.core.tracing.processors.sgp_tracing_processor import _build_sgp_span - - span = _make_span(data={SPAN_ERROR_KEY: {"type": "ValueError", "message": "boom"}}) - with patch(f"{PROCESSOR_MODULE}.create_span", side_effect=_fake_create_span): - sgp_span = _build_sgp_span(span, self._env()) - - assert sgp_span.status == "ERROR" - assert sgp_span.metadata["error"] is True - assert sgp_span.metadata["error_type"] == "ValueError" - assert sgp_span.metadata["error_message"] == "boom" - - def test_no_error_leaves_status_success(self): - from agentex.lib.core.tracing.processors.sgp_tracing_processor import _build_sgp_span - - span = _make_span(data={"__span_type__": "LLM"}) - with patch(f"{PROCESSOR_MODULE}.create_span", side_effect=_fake_create_span): - sgp_span = _build_sgp_span(span, self._env()) - - assert sgp_span.status == "SUCCESS" - assert "error" not in sgp_span.metadata diff --git a/tests/lib/core/tracing/test_span_queue.py b/tests/lib/core/tracing/test_span_queue.py deleted file mode 100644 index b8092daca..000000000 --- a/tests/lib/core/tracing/test_span_queue.py +++ /dev/null @@ -1,893 +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 ( - _DEFAULT_BATCH_SIZE, - SpanEventType, - AsyncSpanQueue, -) - - -def _make_span(span_id: str | None = None) -> Span: - return Span( - id=span_id or str(uuid.uuid4()), - name="test-span", - start_time=datetime.now(UTC), - trace_id="trace-1", - ) - - -def _make_processor(**overrides: AsyncMock) -> AsyncMock: - """Build a mock processor compatible with the queue's batched dispatch. - - The queue now calls on_spans_start(list) / on_spans_end(list) on each - processor. Mirror the behavior of AsyncTracingProcessor's default fallback - by fanning out the list to per-span calls concurrently, so tests that - assert on on_span_start / on_span_end continue to observe per-span calls. - """ - proc = AsyncMock() - proc.on_span_start = overrides.get("on_span_start", AsyncMock()) - proc.on_span_end = overrides.get("on_span_end", AsyncMock()) - - async def _fanout_start(spans: list[Span]) -> None: - await asyncio.gather(*(proc.on_span_start(s) for s in spans), return_exceptions=True) - - async def _fanout_end(spans: list[Span]) -> None: - await asyncio.gather(*(proc.on_span_end(s) for s in spans), return_exceptions=True) - - proc.on_spans_start = AsyncMock(side_effect=_fanout_start) - proc.on_spans_end = AsyncMock(side_effect=_fanout_end) - return proc - - -class TestAsyncSpanQueueNonBlocking: - async def test_enqueue_does_not_block(self): - started = asyncio.Event() - - async def slow_start(span: Span) -> None: - started.set() - await asyncio.sleep(1.0) - - slow_processor = _make_processor( - on_span_start=AsyncMock(side_effect=slow_start), - ) - queue = AsyncSpanQueue() - span = _make_span() - - start = time.monotonic() - queue.enqueue(SpanEventType.START, span, [slow_processor]) - elapsed = time.monotonic() - start - - assert elapsed < 0.01, f"enqueue took {elapsed:.3f}s โ€” should be instant" - - # Wait for the processor to start (proves it was called) - await asyncio.wait_for(started.wait(), timeout=2.0) - await queue.shutdown() - - -class TestAsyncSpanQueueOrdering: - async def test_per_span_start_before_end(self): - """START always completes before END for the same span, even with batching.""" - call_log: list[tuple[str, str]] = [] - - async def record_start(span: Span) -> None: - call_log.append(("start", span.id)) - - async def record_end(span: Span) -> None: - call_log.append(("end", span.id)) - - proc = _make_processor( - on_span_start=AsyncMock(side_effect=record_start), - on_span_end=AsyncMock(side_effect=record_end), - ) - queue = AsyncSpanQueue() - - span_a = _make_span("span-a") - span_b = _make_span("span-b") - - queue.enqueue(SpanEventType.START, span_a, [proc]) - queue.enqueue(SpanEventType.END, span_a, [proc]) - queue.enqueue(SpanEventType.START, span_b, [proc]) - queue.enqueue(SpanEventType.END, span_b, [proc]) - - await queue.shutdown() - - # All 4 events should fire - assert len(call_log) == 4 - - # Per-span invariant: START before END - for span_id in ("span-a", "span-b"): - start_idx = next(i for i, (ev, sid) in enumerate(call_log) if ev == "start" and sid == span_id) - end_idx = next(i for i, (ev, sid) in enumerate(call_log) if ev == "end" and sid == span_id) - assert start_idx < end_idx, f"START should come before END for {span_id}" - - # All STARTs before all ENDs within a batch - start_indices = [i for i, (ev, _) in enumerate(call_log) if ev == "start"] - end_indices = [i for i, (ev, _) in enumerate(call_log) if ev == "end"] - assert max(start_indices) < min(end_indices), "All STARTs should complete before any END" - - -class TestAsyncSpanQueueErrorHandling: - async def test_error_in_processor_does_not_stop_drain(self): - call_count = 0 - - async def failing_start(span: Span) -> None: - nonlocal call_count - call_count += 1 - if call_count == 1: - raise RuntimeError("simulated failure") - - proc = _make_processor( - on_span_start=AsyncMock(side_effect=failing_start), - ) - queue = AsyncSpanQueue() - - queue.enqueue(SpanEventType.START, _make_span(), [proc]) - queue.enqueue(SpanEventType.START, _make_span(), [proc]) - - await queue.shutdown() - - assert call_count == 2, "Second event should still be processed after first fails" - - -class TestAsyncSpanQueueShutdown: - async def test_shutdown_drains_remaining_items(self): - processed: list[str] = [] - - async def track(span: Span) -> None: - processed.append(span.id) - - proc = _make_processor(on_span_start=AsyncMock(side_effect=track)) - queue = AsyncSpanQueue() - - for i in range(5): - queue.enqueue(SpanEventType.START, _make_span(f"span-{i}"), [proc]) - - await queue.shutdown() - - assert len(processed) == 5 - - async def test_shutdown_timeout(self): - async def stuck_start(span: Span) -> None: - await asyncio.sleep(60) - - stuck_processor = _make_processor( - on_span_start=AsyncMock(side_effect=stuck_start), - ) - queue = AsyncSpanQueue() - queue.enqueue(SpanEventType.START, _make_span(), [stuck_processor]) - - # Give the drain loop a moment to pick up the item - await asyncio.sleep(0.05) - - start = time.monotonic() - await queue.shutdown(timeout=0.1) - elapsed = time.monotonic() - start - - assert elapsed < 1.0, f"shutdown should not hang โ€” took {elapsed:.1f}s" - - async def test_enqueue_after_shutdown_is_dropped(self): - proc = _make_processor() - queue = AsyncSpanQueue() - await queue.shutdown() - - queue.enqueue(SpanEventType.START, _make_span(), [proc]) - - proc.on_span_start.assert_not_called() - - -class TestAsyncSpanQueueBatchConcurrency: - async def test_batch_processes_multiple_items_concurrently(self): - """Items in the same batch should run concurrently, not serially.""" - concurrency = 0 - max_concurrency = 0 - lock = asyncio.Lock() - - async def slow_start(span: Span) -> None: - nonlocal concurrency, max_concurrency - async with lock: - concurrency += 1 - max_concurrency = max(max_concurrency, concurrency) - await asyncio.sleep(0.05) - async with lock: - concurrency -= 1 - - proc = _make_processor(on_span_start=AsyncMock(side_effect=slow_start)) - queue = AsyncSpanQueue() - - # Enqueue 10 START events before the drain loop runs โ€” they should - # all land in the same batch and be processed concurrently. - for i in range(10): - queue.enqueue(SpanEventType.START, _make_span(f"span-{i}"), [proc]) - - await queue.shutdown() - - assert max_concurrency > 1, f"Expected concurrent processing, but max concurrency was {max_concurrency}" - - async def test_batch_faster_than_serial(self): - """Batched drain should be significantly faster than serial for slow processors.""" - n_items = 10 - per_item_delay = 0.05 # 50ms per processor call - - async def slow_start(span: Span) -> None: - await asyncio.sleep(per_item_delay) - - proc = _make_processor(on_span_start=AsyncMock(side_effect=slow_start)) - queue = AsyncSpanQueue() - - for i in range(n_items): - queue.enqueue(SpanEventType.START, _make_span(f"span-{i}"), [proc]) - - start = time.monotonic() - await queue.shutdown() - elapsed = time.monotonic() - start - - serial_time = n_items * per_item_delay - assert elapsed < serial_time * 0.5, ( - f"Batch drain took {elapsed:.3f}s โ€” serial would be {serial_time:.3f}s. " - f"Expected at least 2x speedup from concurrency." - ) - - -class TestProcessItemsPreconditions: - """_process_items assumes every item in the list has the same event_type. - Violating that precondition silently causes END events to be treated as - STARTs (or vice versa), which is a silent data-corruption bug. Guard it - with an assertion.""" - - async def test_mixed_event_types_raise_assertion(self): - from agentex.lib.core.tracing.span_queue import _SpanQueueItem - - proc = AsyncMock() - proc.on_spans_start = AsyncMock() - proc.on_spans_end = AsyncMock() - - mixed = [ - _SpanQueueItem(event_type=SpanEventType.START, span=_make_span("a"), processors=[proc]), - _SpanQueueItem(event_type=SpanEventType.END, span=_make_span("b"), processors=[proc]), - ] - - try: - await AsyncSpanQueue()._process_items(mixed) - except AssertionError: - return - else: - raise AssertionError("Expected AssertionError for mixed event types") - - -class TestAsyncSpanQueueBatchedDispatch: - """The queue should dispatch a whole drain batch to each processor via the - batched methods (on_spans_start / on_spans_end) in one call per processor, - so processors that support real HTTP batching can send one request instead - of N. - """ - - async def test_batched_start_dispatch_single_call_per_drain(self): - received: list[list[str]] = [] - - async def capture_starts(spans: list[Span]) -> None: - received.append([s.id for s in spans]) - - proc = AsyncMock() - proc.on_spans_start = AsyncMock(side_effect=capture_starts) - proc.on_spans_end = AsyncMock() - - queue = AsyncSpanQueue() - - # Enqueue several spans synchronously before the drain has a chance to - # run โ€” they should all land in a single drain batch. - ids = [f"span-{i}" for i in range(5)] - for i in ids: - queue.enqueue(SpanEventType.START, _make_span(i), [proc]) - - await queue.shutdown() - - # on_spans_start must have been called exactly once with all 5 spans. - assert proc.on_spans_start.call_count == 1, f"Expected one batched call, got {proc.on_spans_start.call_count}" - assert received == [ids] - - async def test_batched_end_dispatch_single_call_per_drain(self): - received: list[list[str]] = [] - - async def capture_ends(spans: list[Span]) -> None: - received.append([s.id for s in spans]) - - proc = AsyncMock() - proc.on_spans_start = AsyncMock() - proc.on_spans_end = AsyncMock(side_effect=capture_ends) - - queue = AsyncSpanQueue() - - ids = [f"span-{i}" for i in range(5)] - for i in ids: - queue.enqueue(SpanEventType.END, _make_span(i), [proc]) - - await queue.shutdown() - - assert proc.on_spans_end.call_count == 1 - assert received == [ids] - - -class TestAsyncSpanQueueLinger: - """The drain loop should linger briefly after the first item arrives so - that concurrently-emitted spans coalesce into one batch, instead of each - span producing its own size-1 drain cycle. - """ - - async def test_linger_coalesces_staggered_enqueues_into_one_batch(self): - """Spans enqueued a few ms apart should land in the SAME drain batch - when the linger window is wider than the gap between them. - """ - received: list[list[str]] = [] - - async def capture_starts(spans: list[Span]) -> None: - received.append([s.id for s in spans]) - - proc = AsyncMock() - proc.on_spans_start = AsyncMock(side_effect=capture_starts) - proc.on_spans_end = AsyncMock() - - # Linger of 100ms; we enqueue 3 items 20ms apart, well inside the window. - queue = AsyncSpanQueue(linger_ms=100) - - for i in range(3): - queue.enqueue(SpanEventType.START, _make_span(f"span-{i}"), [proc]) - await asyncio.sleep(0.02) - - await queue.shutdown() - - # All three should arrive in one batched call thanks to the linger. - assert proc.on_spans_start.call_count == 1, ( - f"Expected one batch from linger-coalesced enqueues, got " - f"{proc.on_spans_start.call_count} batches: {received}" - ) - assert received == [["span-0", "span-1", "span-2"]] - - async def test_linger_zero_drains_immediately(self): - """With linger_ms=0, the drain loop should NOT wait โ€” staggered - enqueues produce separate batches (back-compat with prior behavior). - """ - received: list[list[str]] = [] - - async def capture_starts(spans: list[Span]) -> None: - received.append([s.id for s in spans]) - - proc = AsyncMock() - proc.on_spans_start = AsyncMock(side_effect=capture_starts) - proc.on_spans_end = AsyncMock() - - queue = AsyncSpanQueue(linger_ms=0) - - for i in range(3): - queue.enqueue(SpanEventType.START, _make_span(f"span-{i}"), [proc]) - # Give the drain loop time to pick up and process each one. - await asyncio.sleep(0.05) - - await queue.shutdown() - - # With no linger, each staggered enqueue produces its own batch. - assert proc.on_spans_start.call_count == 3, ( - f"Expected three size-1 batches without linger, got {proc.on_spans_start.call_count}: {received}" - ) - - async def test_linger_respects_batch_size_cap(self): - """The linger must not push batches over batch_size.""" - received: list[list[str]] = [] - - async def capture_starts(spans: list[Span]) -> None: - received.append([s.id for s in spans]) - - proc = AsyncMock() - proc.on_spans_start = AsyncMock(side_effect=capture_starts) - proc.on_spans_end = AsyncMock() - - # Tight batch cap, linger wide enough to coalesce but not so large - # that the tail singleton stalls the test for hundreds of ms. - queue = AsyncSpanQueue(batch_size=3, linger_ms=50) - - ids = [f"span-{i}" for i in range(7)] - for i in ids: - queue.enqueue(SpanEventType.START, _make_span(i), [proc]) - - await queue.shutdown() - - # 7 spans / batch_size=3 โ‡’ at least 3 batches (3, 3, 1). None should - # exceed the cap. - for batch in received: - assert len(batch) <= 3, f"Batch exceeded cap: {batch}" - assert sum(len(b) for b in received) == 7 - - - -class _FakeHTTPError(Exception): - """Mimics an SGP/httpx status error: carries a ``status_code`` attribute.""" - - def __init__(self, status_code: int) -> None: - self.status_code = status_code - super().__init__(f"HTTP {status_code}") - - -class TestAsyncSpanQueueDropObservability: - """Silent span loss should be counted so it is measurable, and a bounded - queue should shed load deterministically instead of growing without limit. - """ - - async def test_full_queue_drops_are_counted(self): - release = asyncio.Event() - - async def block_first(spans: list[Span]) -> None: - # Block the drain on its first batch so the queue can fill behind it. - await release.wait() - - proc = AsyncMock() - proc.on_spans_start = AsyncMock(side_effect=block_first) - proc.on_spans_end = AsyncMock() - - # max_size=1, no linger, concurrency=1: the drain dispatches item-0 and - # then blocks at the in-flight cap; item-1 fills the queue; items 2 and 3 - # are dropped. - queue = AsyncSpanQueue(max_size=1, linger_ms=0, concurrency=1) - - queue.enqueue(SpanEventType.START, _make_span("s0"), [proc]) - await asyncio.sleep(0.02) # let the drain pick up s0 and block - queue.enqueue(SpanEventType.START, _make_span("s1"), [proc]) - queue.enqueue(SpanEventType.START, _make_span("s2"), [proc]) - queue.enqueue(SpanEventType.START, _make_span("s3"), [proc]) - - assert queue.dropped_spans == 2, f"expected 2 dropped, got {queue.dropped_spans}" - - release.set() - await queue.shutdown() - - async def test_no_drops_under_normal_load(self): - proc = _make_processor() - queue = AsyncSpanQueue() - for i in range(5): - queue.enqueue(SpanEventType.START, _make_span(f"s{i}"), [proc]) - await queue.shutdown() - assert queue.dropped_spans == 0 - - -class TestAsyncSpanQueueRetry: - """Transient HTTP failures (429/5xx) should be re-enqueued up to a bounded - number of attempts; auth/other errors must be dropped (and counted), never - retried. - """ - - async def test_retryable_status_is_reenqueued_and_eventually_succeeds(self): - attempts = 0 - - async def fail_then_succeed(spans: list[Span]) -> None: - nonlocal attempts - attempts += 1 - if attempts == 1: - raise _FakeHTTPError(503) - # second attempt succeeds - - proc = AsyncMock() - proc.on_spans_start = AsyncMock(side_effect=fail_then_succeed) - proc.on_spans_end = AsyncMock() - - queue = AsyncSpanQueue(max_retries=3, linger_ms=0) - queue.enqueue(SpanEventType.START, _make_span("s0"), [proc]) - await queue.shutdown() - - assert attempts == 2, "503 should be retried once, then succeed" - assert queue.dropped_spans == 0, "successful retry must not count as a drop" - - async def test_non_retryable_status_is_dropped_not_retried(self): - attempts = 0 - - async def always_401(spans: list[Span]) -> None: - nonlocal attempts - attempts += 1 - raise _FakeHTTPError(401) - - proc = AsyncMock() - proc.on_spans_start = AsyncMock(side_effect=always_401) - proc.on_spans_end = AsyncMock() - - queue = AsyncSpanQueue(max_retries=3, linger_ms=0) - queue.enqueue(SpanEventType.START, _make_span("s0"), [proc]) - await queue.shutdown() - - assert attempts == 1, "401 is non-retryable โ€” must be tried exactly once" - assert queue.dropped_spans == 1 - - async def test_non_http_exception_is_not_retried(self): - """A plain bug (no status_code) must not be retried into an infinite - loop โ€” preserves the original drain-continues-on-error contract.""" - attempts = 0 - - async def boom(spans: list[Span]) -> None: - nonlocal attempts - attempts += 1 - raise RuntimeError("bug, not transient") - - proc = AsyncMock() - proc.on_spans_start = AsyncMock(side_effect=boom) - proc.on_spans_end = AsyncMock() - - queue = AsyncSpanQueue(max_retries=3, linger_ms=0) - queue.enqueue(SpanEventType.START, _make_span("s0"), [proc]) - await queue.shutdown() - - assert attempts == 1 - assert queue.dropped_spans == 1 - - async def test_retryable_exhausts_attempts_then_drops(self): - attempts = 0 - - async def always_503(spans: list[Span]) -> None: - nonlocal attempts - attempts += 1 - raise _FakeHTTPError(503) - - proc = AsyncMock() - proc.on_spans_start = AsyncMock(side_effect=always_503) - proc.on_spans_end = AsyncMock() - - queue = AsyncSpanQueue(max_retries=3, linger_ms=0) - queue.enqueue(SpanEventType.START, _make_span("s0"), [proc]) - await queue.shutdown() - - assert attempts == 3, "should try up to max_retries times" - assert queue.dropped_spans == 1 - - -class TestAsyncSpanQueueConcurrency: - """Span export should issue multiple batch requests concurrently (bounded), - so per-pod egress isn't capped at one in-flight request โ€” while still - guaranteeing a span's START send completes before its END send. - """ - - async def test_batches_dispatched_concurrently_up_to_bound(self): - current = 0 - max_seen = 0 - lock = asyncio.Lock() - - async def slow_start(spans: list[Span]) -> None: - nonlocal current, max_seen - async with lock: - current += 1 - max_seen = max(max_seen, current) - await asyncio.sleep(0.05) - async with lock: - current -= 1 - - proc = AsyncMock() - proc.on_spans_start = AsyncMock(side_effect=slow_start) - proc.on_spans_end = AsyncMock() - - # batch_size=1 โ†’ each span is its own batch/send; concurrency=4 caps - # simultaneous in-flight sends. - queue = AsyncSpanQueue(batch_size=1, linger_ms=0, concurrency=4) - for i in range(8): - queue.enqueue(SpanEventType.START, _make_span(f"s{i}"), [proc]) - - await queue.shutdown() - - assert proc.on_spans_start.call_count == 8 - assert 2 <= max_seen <= 4, f"expected bounded concurrency (2..4), saw {max_seen}" - - async def test_concurrency_one_serializes(self): - current = 0 - max_seen = 0 - lock = asyncio.Lock() - - async def slow_start(spans: list[Span]) -> None: - nonlocal current, max_seen - async with lock: - current += 1 - max_seen = max(max_seen, current) - await asyncio.sleep(0.03) - async with lock: - current -= 1 - - proc = AsyncMock() - proc.on_spans_start = AsyncMock(side_effect=slow_start) - proc.on_spans_end = AsyncMock() - - queue = AsyncSpanQueue(batch_size=1, linger_ms=0, concurrency=1) - for i in range(4): - queue.enqueue(SpanEventType.START, _make_span(f"s{i}"), [proc]) - - await queue.shutdown() - - assert max_seen == 1, f"concurrency=1 must serialize sends, saw {max_seen}" - - async def test_concurrent_is_faster_than_serial(self): - async def slow_start(spans: list[Span]) -> None: - await asyncio.sleep(0.05) - - proc = AsyncMock() - proc.on_spans_start = AsyncMock(side_effect=slow_start) - proc.on_spans_end = AsyncMock() - - queue = AsyncSpanQueue(batch_size=1, linger_ms=0, concurrency=8) - for i in range(8): - queue.enqueue(SpanEventType.START, _make_span(f"s{i}"), [proc]) - - start = time.monotonic() - await queue.shutdown() - elapsed = time.monotonic() - start - - serial = 8 * 0.05 - assert elapsed < serial * 0.5, f"concurrent drain took {elapsed:.3f}s; serial would be {serial:.3f}s" - - async def test_end_waits_for_start_of_same_span(self): - """The per-span ordering invariant: a span's END upsert must not be sent - until its START upsert has completed, even with concurrency enabled.""" - log: list[tuple[str, str]] = [] - - async def on_start(spans: list[Span]) -> None: - log.append(("start_enter", spans[0].id)) - await asyncio.sleep(0.05) - log.append(("start_exit", spans[0].id)) - - async def on_end(spans: list[Span]) -> None: - log.append(("end_enter", spans[0].id)) - await asyncio.sleep(0.01) - log.append(("end_exit", spans[0].id)) - - proc = AsyncMock() - proc.on_spans_start = AsyncMock(side_effect=on_start) - proc.on_spans_end = AsyncMock(side_effect=on_end) - - queue = AsyncSpanQueue(batch_size=1, linger_ms=0, concurrency=4) - queue.enqueue(SpanEventType.START, _make_span("A"), [proc]) - await asyncio.sleep(0.01) # let the START send begin (and block on sleep) - queue.enqueue(SpanEventType.END, _make_span("A"), [proc]) - - await queue.shutdown() - - # END must not enter until START has exited for the same span. - start_exit = log.index(("start_exit", "A")) - end_enter = log.index(("end_enter", "A")) - assert start_exit < end_enter, f"END began before START completed: {log}" - - -class TestAsyncSpanQueueIntegration: - async def test_integration_with_async_trace(self): - call_log: list[tuple[str, str]] = [] - - async def record_start(span: Span) -> None: - call_log.append(("start", span.id)) - - async def record_end(span: Span) -> None: - call_log.append(("end", span.id)) - - proc = _make_processor( - on_span_start=AsyncMock(side_effect=record_start), - on_span_end=AsyncMock(side_effect=record_end), - ) - queue = AsyncSpanQueue() - - # Patch get_async_tracing_processors to return our mock - with patch( - "agentex.lib.core.tracing.trace.get_default_span_queue", - return_value=queue, - ): - from agentex.lib.core.tracing.trace import AsyncTrace - - mock_client = MagicMock() - trace = AsyncTrace( - processors=[proc], - client=mock_client, - trace_id="test-trace", - span_queue=queue, - ) - - async with trace.span("test-operation") as span: - output: dict[str, object] = {"result": "ok"} - span.output = output - - await queue.shutdown() - - assert len(call_log) == 2 - assert call_log[0][0] == "start" - assert call_log[1][0] == "end" - # Same span ID for both events - assert call_log[0][1] == call_log[1][1] - - async def test_end_event_preserves_modified_input(self): - """END event should carry span.input so modifications after start are preserved.""" - start_spans: list[Span] = [] - end_spans: list[Span] = [] - - async def capture_start(span: Span) -> None: - start_spans.append(span) - - async def capture_end(span: Span) -> None: - end_spans.append(span) - - proc = _make_processor( - on_span_start=AsyncMock(side_effect=capture_start), - on_span_end=AsyncMock(side_effect=capture_end), - ) - queue = AsyncSpanQueue() - - from agentex.lib.core.tracing.trace import AsyncTrace - - mock_client = MagicMock() - trace = AsyncTrace( - processors=[proc], - client=mock_client, - trace_id="test-trace", - span_queue=queue, - ) - - initial_input: dict[str, object] = {"messages": [{"role": "user", "content": "hello"}]} - async with trace.span("llm-call", input=initial_input) as span: - # Simulate modifying input after start (e.g. chatbot appending messages) - messages = cast(list[dict[str, str]], cast(dict[str, object], span.input)["messages"]) - messages.append({"role": "assistant", "content": "hi there"}) - messages.append({"role": "user", "content": "how are you?"}) - span.output = cast(dict[str, object], {"response": "I'm good!"}) - - await queue.shutdown() - - assert len(start_spans) == 1 - assert len(end_spans) == 1 - - # START should carry the original input (serialized at start time) - assert start_spans[0].input is not None - assert len(cast(dict[str, list[object]], start_spans[0].input)["messages"]) == 1 # only the original message - - # END should carry the modified input (re-serialized at end time) - assert end_spans[0].input is not None - assert len(cast(dict[str, list[object]], end_spans[0].input)["messages"]) == 3 # all three messages - - # END should still carry output and end_time - assert end_spans[0].output is not None - assert end_spans[0].end_time is not None - - -class TestAsyncSpanQueueMetrics: - async def test_batch_coalesced_records_depth_including_batch(self, monkeypatch): - monkeypatch.setenv("AGENTEX_TRACING_METRICS", "1") - import agentex.lib.core.observability.tracing_metrics_recording as recording - - recording._metrics_enabled = None - proc = _make_processor() - queue = AsyncSpanQueue(linger_ms=0) - recorded_depths: list[int] = [] - - def capture_coalesced(*, queue_depth: int, batch_items: object) -> None: - recorded_depths.append(queue_depth) - - with patch.object(recording, "record_batch_coalesced", side_effect=capture_coalesced): - for _ in range(3): - queue.enqueue(SpanEventType.START, _make_span(), [proc]) - await asyncio.sleep(0.05) - await queue.shutdown() - - assert recorded_depths, "expected at least one coalesced batch" - assert recorded_depths[0] >= 3, ( - f"queue_depth should include batch items removed from queue, got {recorded_depths[0]}" - ) - - async def test_enqueue_records_enqueued_metric(self, monkeypatch): - monkeypatch.setenv("AGENTEX_TRACING_METRICS", "1") - import agentex.lib.core.observability.tracing_metrics_recording as recording - - recording._metrics_enabled = None - recording._tracing = None - mock_metrics = MagicMock() - proc = _make_processor() - queue = AsyncSpanQueue() - - with patch( - "agentex.lib.core.observability.tracing_metrics.get_tracing_metrics", - return_value=mock_metrics, - ): - queue.enqueue(SpanEventType.START, _make_span(), [proc]) - await asyncio.sleep(0.05) - await queue.shutdown() - - mock_metrics.span_events_enqueued.add.assert_any_call(1, {"event_type": "start"}) - - async def test_enqueue_during_shutdown_records_dropped_metric(self, monkeypatch): - monkeypatch.setenv("AGENTEX_TRACING_METRICS", "1") - import agentex.lib.core.observability.tracing_metrics_recording as recording - - recording._metrics_enabled = None - recording._tracing = None - mock_metrics = MagicMock() - proc = _make_processor() - queue = AsyncSpanQueue(linger_ms=0) - - with patch( - "agentex.lib.core.observability.tracing_metrics.get_tracing_metrics", - return_value=mock_metrics, - ): - queue.enqueue(SpanEventType.START, _make_span(), [proc]) - await asyncio.sleep(0.05) - queue._stopping = True - queue.enqueue(SpanEventType.END, _make_span(), [proc]) - await queue.shutdown() - - mock_metrics.span_events_dropped.add.assert_any_call(1, {"reason": "shutdown"}) - - async def test_processor_failure_records_export_failure(self, monkeypatch): - monkeypatch.setenv("AGENTEX_TRACING_METRICS", "1") - import agentex.lib.core.observability.tracing_metrics_recording as recording - - recording._metrics_enabled = None - recording._tracing = None - mock_metrics = MagicMock() - - class ExportError(Exception): - pass - - proc = AsyncMock() - proc.on_spans_start = AsyncMock(side_effect=ExportError("Error code: 401 - denied")) - proc.on_spans_end = AsyncMock() - queue = AsyncSpanQueue() - - with patch( - "agentex.lib.core.observability.tracing_metrics.get_tracing_metrics", - return_value=mock_metrics, - ): - queue.enqueue(SpanEventType.START, _make_span(), [proc]) - await asyncio.sleep(0.05) - await queue.shutdown() - - mock_metrics.export_batch_failures.add.assert_called_once() - mock_metrics.export_span_failures.add.assert_called_once() - - async def test_enqueue_overhead_with_metrics_disabled(self, monkeypatch): - monkeypatch.setenv("AGENTEX_TRACING_METRICS", "0") - import agentex.lib.core.observability.tracing_metrics_recording as recording - - recording._metrics_enabled = None - recording._tracing = None - proc = _make_processor() - queue = AsyncSpanQueue() - - with patch( - "agentex.lib.core.observability.tracing_metrics.get_tracing_metrics" - ) as mock_get: - start = time.monotonic() - for _ in range(200): - queue.enqueue(SpanEventType.START, _make_span(), [proc]) - elapsed = time.monotonic() - start - await queue.shutdown() - - assert elapsed < 0.05, f"disabled metrics enqueue too slow: {elapsed:.3f}s" - mock_get.assert_not_called() - - -class TestAsyncSpanQueueBatchSizeConfig: - """batch_size resolution: explicit arg > AGENTEX_SPAN_QUEUE_BATCH_SIZE env > default.""" - - async def test_default_batch_size(self, monkeypatch): - monkeypatch.delenv("AGENTEX_SPAN_QUEUE_BATCH_SIZE", raising=False) - assert AsyncSpanQueue()._batch_size == _DEFAULT_BATCH_SIZE - - async def test_explicit_arg_overrides_default(self, monkeypatch): - monkeypatch.delenv("AGENTEX_SPAN_QUEUE_BATCH_SIZE", raising=False) - assert AsyncSpanQueue(batch_size=10)._batch_size == 10 - - async def test_explicit_arg_clamped_to_min_one(self, monkeypatch): - monkeypatch.delenv("AGENTEX_SPAN_QUEUE_BATCH_SIZE", raising=False) - assert AsyncSpanQueue(batch_size=0)._batch_size == 1 - - async def test_env_used_when_arg_is_none(self, monkeypatch): - monkeypatch.setenv("AGENTEX_SPAN_QUEUE_BATCH_SIZE", "500") - assert AsyncSpanQueue()._batch_size == 500 - - async def test_explicit_arg_beats_env(self, monkeypatch): - monkeypatch.setenv("AGENTEX_SPAN_QUEUE_BATCH_SIZE", "500") - assert AsyncSpanQueue(batch_size=7)._batch_size == 7 - - async def test_invalid_env_falls_back_to_default(self, monkeypatch): - monkeypatch.setenv("AGENTEX_SPAN_QUEUE_BATCH_SIZE", "not-an-int") - assert AsyncSpanQueue()._batch_size == _DEFAULT_BATCH_SIZE diff --git a/tests/lib/core/tracing/test_span_queue_load.py b/tests/lib/core/tracing/test_span_queue_load.py 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 99982d851..000000000 --- a/tests/lib/test_claude_agents_activities.py +++ /dev/null @@ -1,737 +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 -_created_pkg_stubs: list[str] = [] -for _pkg in [ - "agentex.lib.core.temporal.plugins", - "agentex.lib.core.temporal.plugins.claude_agents", - "agentex.lib.core.temporal.plugins.openai_agents", - "agentex.lib.core.temporal.plugins.openai_agents.interceptors", -]: - if _pkg not in sys.modules: - _mod = ModuleType(_pkg) - _mod.__path__ = [] # type: ignore[attr-defined] - _mod.__package__ = _pkg - sys.modules[_pkg] = _mod - _created_pkg_stubs.append(_pkg) - -# Load activities.py directly from its file path -_spec = importlib.util.spec_from_file_location( - "agentex.lib.core.temporal.plugins.claude_agents.activities", - _ACTIVITIES_PATH, -) -assert _spec is not None and _spec.loader is not None -_activities_mod = importlib.util.module_from_spec(_spec) -sys.modules[_spec.name] = _activities_mod -_spec.loader.exec_module(_activities_mod) - -# Drop the placeholder packages now that the module is loaded: their empty -# __path__ would otherwise block real imports of agentex.lib.core.temporal.* -# submodules in every test collected after this file. The loaded module keeps -# its bindings. -for _pkg in _created_pkg_stubs: - sys.modules.pop(_pkg, None) - -_reconstruct_agent_defs = _activities_mod._reconstruct_agent_defs # type: ignore[attr-defined] -claude_options_to_dict = _activities_mod.claude_options_to_dict # type: ignore[attr-defined] - - -class TestClaudeOptionsToDict: - """Tests for claude_options_to_dict serialization helper.""" - - def test_basic_fields(self): - options = ClaudeAgentOptions( - cwd="/workspace", - allowed_tools=["Read", "Write"], - permission_mode="acceptEdits", - system_prompt="Be helpful.", - ) - result = claude_options_to_dict(options) - assert result["cwd"] == "/workspace" - assert result["allowed_tools"] == ["Read", "Write"] - assert result["permission_mode"] == "acceptEdits" - assert result["system_prompt"] == "Be helpful." - - def test_excludes_defaults(self): - """Fields left at their default value should not appear in the dict.""" - options = ClaudeAgentOptions(cwd="/workspace") - result = claude_options_to_dict(options) - assert "cwd" in result - # These are all defaults and should be absent - assert "continue_conversation" not in result - assert "include_partial_messages" not in result - assert "fork_session" not in result - assert "disallowed_tools" not in result - - def test_excludes_non_serializable_fields(self): - """Callbacks and file objects should never appear in the dict.""" - options = ClaudeAgentOptions( - cwd="/workspace", - can_use_tool=lambda *_: True, - stderr=lambda msg: None, - ) - result = claude_options_to_dict(options) - assert "can_use_tool" not in result - assert "stderr" not in result - assert "debug_stderr" not in result - assert "hooks" not in result - - def test_mcp_servers_included(self): - options = ClaudeAgentOptions( - cwd="/workspace", - mcp_servers={"my-server": {"command": "npx", "args": ["server"]}}, - ) - result = claude_options_to_dict(options) - assert result["mcp_servers"] == {"my-server": {"command": "npx", "args": ["server"]}} - - def test_agents_included(self): - agents = { - "reviewer": AgentDefinition( - description="Code reviewer", - prompt="Review code.", - tools=["Read"], - model="sonnet", - ) - } - options = ClaudeAgentOptions(cwd="/workspace", agents=agents) - result = claude_options_to_dict(options) - assert "agents" in result - assert "reviewer" in result["agents"] - - def test_model_and_budget_fields(self): - options = ClaudeAgentOptions( - cwd="/workspace", - model="opus", - max_turns=5, - max_budget_usd=1.0, - max_thinking_tokens=8000, - ) - result = claude_options_to_dict(options) - assert result["model"] == "opus" - assert result["max_turns"] == 5 - assert result["max_budget_usd"] == 1.0 - assert result["max_thinking_tokens"] == 8000 - - def test_resume_session(self): - options = ClaudeAgentOptions( - cwd="/workspace", - resume="session-abc-123", - ) - result = claude_options_to_dict(options) - assert result["resume"] == "session-abc-123" - - def test_roundtrip_constructs_options(self): - """The dict produced by claude_options_to_dict can construct a new ClaudeAgentOptions.""" - original = ClaudeAgentOptions( - cwd="/workspace", - allowed_tools=["Read", "Bash"], - permission_mode="acceptEdits", - model="sonnet", - max_turns=3, - ) - d = claude_options_to_dict(original) - reconstructed = ClaudeAgentOptions(**d) - assert reconstructed.cwd == original.cwd - assert reconstructed.allowed_tools == original.allowed_tools - assert reconstructed.permission_mode == original.permission_mode - assert reconstructed.model == original.model - assert reconstructed.max_turns == original.max_turns - - -class TestReconstructAgentDefs: - """Tests for _reconstruct_agent_defs helper.""" - - def test_none_input(self): - assert _reconstruct_agent_defs(None) is None - - def test_empty_dict(self): - assert _reconstruct_agent_defs({}) is None - - def test_already_agent_definitions(self): - agent = AgentDefinition(description="test", prompt="test prompt") - result = _reconstruct_agent_defs({"a": agent}) - assert result is not None - assert result["a"] is agent - - def test_dict_input(self): - """Temporal serializes dataclasses to dicts - verify reconstruction.""" - raw = { - "reviewer": { - "description": "Code reviewer", - "prompt": "Review code.", - "tools": ["Read", "Grep"], - "model": "sonnet", - } - } - result = _reconstruct_agent_defs(raw) - assert result is not None - assert isinstance(result["reviewer"], AgentDefinition) - assert result["reviewer"].description == "Code reviewer" - assert result["reviewer"].prompt == "Review code." - assert result["reviewer"].tools == ["Read", "Grep"] - assert result["reviewer"].model == "sonnet" - - def test_mixed_input(self): - """Mix of already-constructed and dict-serialized agents.""" - existing = AgentDefinition(description="existing", prompt="p") - raw = {"description": "from_dict", "prompt": "p2", "tools": None, "model": None} - result = _reconstruct_agent_defs({"a": existing, "b": raw}) - assert result is not None - assert isinstance(result["a"], AgentDefinition) - assert isinstance(result["b"], AgentDefinition) - assert result["a"].description == "existing" - assert result["b"].description == "from_dict" - - -class TestRunClaudeAgentActivity: - """Tests for the run_claude_agent_activity Temporal activity.""" - - @patch( - "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_task_id", - ) - @patch( - "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_trace_id", - ) - @patch( - "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_parent_span_id", - ) - @patch( - "agentex.lib.core.temporal.plugins.claude_agents.activities.ClaudeSDKClient", - ) - @patch( - "agentex.lib.core.temporal.plugins.claude_agents.activities.create_streaming_hooks", - ) - @patch( - "agentex.lib.core.temporal.plugins.claude_agents.activities.adk", - ) - async def test_passes_claude_options_to_sdk( - self, - _mock_adk, - mock_create_hooks, - mock_client_cls, - mock_parent_span_id, - mock_trace_id, - mock_task_id, - ): - """Verify that claude_options extras are merged into ClaudeAgentOptions.""" - from agentex.lib.core.temporal.plugins.claude_agents.activities import ( - run_claude_agent_activity, - ) - - # Set up context vars - mock_task_id.get.return_value = "task-1" - mock_trace_id.get.return_value = "trace-1" - mock_parent_span_id.get.return_value = "span-1" - - # Set up hooks - mock_create_hooks.return_value = {"PreToolUse": [], "PostToolUse": []} - - # Set up client as async context manager - mock_client = AsyncMock() - mock_client.receive_response = MagicMock(return_value=AsyncIteratorMock([])) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - # Extra SDK options passed via claude_options - extra = { - "model": "sonnet", - "mcp_servers": {"my-server": {"command": "npx", "args": ["srv"]}}, - } - - # activity.defn decorates in-place (no __wrapped__), call directly - await run_claude_agent_activity( - prompt="Hello", - workspace_path="/workspace", - allowed_tools=["Read"], - permission_mode="acceptEdits", - claude_options=extra, - ) - - # Verify ClaudeAgentOptions was constructed with both explicit + extra fields - call_args = mock_client_cls.call_args - options = call_args.kwargs.get("options") or call_args[1].get("options") - assert options.cwd == "/workspace" - assert options.allowed_tools == ["Read"] - assert options.model == "sonnet" - assert options.mcp_servers == {"my-server": {"command": "npx", "args": ["srv"]}} - - @patch( - "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_task_id", - ) - @patch( - "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_trace_id", - ) - @patch( - "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_parent_span_id", - ) - @patch( - "agentex.lib.core.temporal.plugins.claude_agents.activities.ClaudeSDKClient", - ) - @patch( - "agentex.lib.core.temporal.plugins.claude_agents.activities.create_streaming_hooks", - ) - @patch( - "agentex.lib.core.temporal.plugins.claude_agents.activities.adk", - ) - async def test_claude_options_not_masked_by_none_explicit_params( - self, - _mock_adk, - mock_create_hooks, - mock_client_cls, - mock_parent_span_id, - mock_trace_id, - mock_task_id, - ): - """claude_options values should not be silently dropped when explicit params are None.""" - from agentex.lib.core.temporal.plugins.claude_agents.activities import ( - run_claude_agent_activity, - ) - - mock_task_id.get.return_value = "task-1" - mock_trace_id.get.return_value = "trace-1" - mock_parent_span_id.get.return_value = "span-1" - mock_create_hooks.return_value = {"PreToolUse": [], "PostToolUse": []} - - mock_client = AsyncMock() - mock_client.receive_response = MagicMock(return_value=AsyncIteratorMock([])) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - # system_prompt explicit param is None (default), but claude_options has a value - await run_claude_agent_activity( - prompt="Hello", - workspace_path="/workspace", - allowed_tools=["Read"], - claude_options={"system_prompt": "Be helpful"}, - ) - - call_args = mock_client_cls.call_args - options = call_args.kwargs.get("options") or call_args[1].get("options") - assert options.system_prompt == "Be helpful" - - @patch( - "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_task_id", - ) - @patch( - "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_trace_id", - ) - @patch( - "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_parent_span_id", - ) - @patch( - "agentex.lib.core.temporal.plugins.claude_agents.activities.ClaudeSDKClient", - ) - @patch( - "agentex.lib.core.temporal.plugins.claude_agents.activities.create_streaming_hooks", - ) - @patch( - "agentex.lib.core.temporal.plugins.claude_agents.activities.adk", - ) - async def test_merges_user_hooks_with_streaming_hooks( - self, - _mock_adk, - mock_create_hooks, - mock_client_cls, - mock_parent_span_id, - mock_trace_id, - mock_task_id, - ): - """User-provided hooks in claude_options should be merged with streaming hooks.""" - from agentex.lib.core.temporal.plugins.claude_agents.activities import ( - run_claude_agent_activity, - ) - - mock_task_id.get.return_value = "task-1" - mock_trace_id.get.return_value = "trace-1" - mock_parent_span_id.get.return_value = "span-1" - - # Streaming hooks - streaming_pre = HookMatcher(matcher=None, hooks=[AsyncMock()]) - mock_create_hooks.return_value = {"PreToolUse": [streaming_pre]} - - mock_client = AsyncMock() - mock_client.receive_response = MagicMock(return_value=AsyncIteratorMock([])) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - # User-provided hook via claude_options - user_pre = HookMatcher(matcher="Bash", hooks=[AsyncMock()]) - - await run_claude_agent_activity( - prompt="Hello", - workspace_path="/workspace", - allowed_tools=["Read"], - claude_options={"hooks": {"PreToolUse": [user_pre]}}, - ) - - call_args = mock_client_cls.call_args - options = call_args.kwargs.get("options") or call_args[1].get("options") - # Should have both streaming and user hooks merged - assert len(options.hooks["PreToolUse"]) == 2 - - -class _AsyncCtxManager: - """Simple async context manager that yields a given value.""" - - def __init__(self, value): - self.value = value - self.exited = False - - async def __aenter__(self): - return self.value - - async def __aexit__(self, *args): - self.exited = True - - -def _setup_activity_mocks( - mock_adk, mock_create_hooks, mock_client_cls, mock_task_id, mock_trace_id, mock_parent_span_id, messages -): - """Common setup for activity tests that send messages through the client.""" - mock_task_id.get.return_value = "task-1" - mock_trace_id.get.return_value = "trace-1" - mock_parent_span_id.get.return_value = "span-1" - mock_create_hooks.return_value = {"PreToolUse": [], "PostToolUse": [], "PostToolUseFailure": []} - - mock_client = AsyncMock() - mock_client.receive_response = MagicMock(return_value=AsyncIteratorMock(messages)) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) - - return mock_adk - - -_ACTIVITY_PATCHES = [ - "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_task_id", - "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_trace_id", - "agentex.lib.core.temporal.plugins.claude_agents.activities.streaming_parent_span_id", - "agentex.lib.core.temporal.plugins.claude_agents.activities.ClaudeSDKClient", - "agentex.lib.core.temporal.plugins.claude_agents.activities.create_streaming_hooks", - "agentex.lib.core.temporal.plugins.claude_agents.activities.adk", -] - - -class TestActivityMessageHandling: - """Tests for run_claude_agent_activity message streaming behavior.""" - - @patch(_ACTIVITY_PATCHES[0]) - @patch(_ACTIVITY_PATCHES[1]) - @patch(_ACTIVITY_PATCHES[2]) - @patch(_ACTIVITY_PATCHES[3]) - @patch(_ACTIVITY_PATCHES[4]) - @patch(_ACTIVITY_PATCHES[5]) - async def test_text_block_opens_and_closes_streaming_context( - self, - mock_adk, - mock_create_hooks, - mock_client_cls, - mock_parent_span_id, - mock_trace_id, - mock_task_id, - ): - """TextBlock should open a text streaming CM, use it for deltas, and close it via __aexit__.""" - from agentex.lib.core.temporal.plugins.claude_agents.activities import run_claude_agent_activity - - text_ctx = MagicMock() - text_ctx.task_message = MagicMock() - text_ctx.stream_update = AsyncMock() - text_cm = _AsyncCtxManager(text_ctx) - - mock_adk = _setup_activity_mocks( - mock_adk, - mock_create_hooks, - mock_client_cls, - mock_task_id, - mock_trace_id, - mock_parent_span_id, - messages=[AssistantMessage(content=[TextBlock(text="Hello world")], model="claude")], - ) - mock_adk.streaming.streaming_task_message_context.return_value = text_cm - - result = await run_claude_agent_activity(prompt="Hi", workspace_path="/ws", allowed_tools=["Read"]) - - # streaming_task_message_context was called to open the text stream - mock_adk.streaming.streaming_task_message_context.assert_called() - # The CM was entered and then exited via close_text_stream (uses __aexit__) - assert text_cm.exited is True - # Result includes the text in serialized_messages - assert len(result["messages"]) == 1 - assert result["messages"][0]["content"] == "Hello world" - - @patch(_ACTIVITY_PATCHES[0]) - @patch(_ACTIVITY_PATCHES[1]) - @patch(_ACTIVITY_PATCHES[2]) - @patch(_ACTIVITY_PATCHES[3]) - @patch(_ACTIVITY_PATCHES[4]) - @patch(_ACTIVITY_PATCHES[5]) - async def test_tool_use_block_closes_text_stream_first( - self, - mock_adk, - mock_create_hooks, - mock_client_cls, - mock_parent_span_id, - mock_trace_id, - mock_task_id, - ): - """ToolUseBlock should close any open text stream before streaming the tool request.""" - from agentex.lib.core.temporal.plugins.claude_agents.activities import run_claude_agent_activity - - text_ctx = MagicMock() - text_ctx.task_message = MagicMock() - text_ctx.stream_update = AsyncMock() - text_cm = _AsyncCtxManager(text_ctx) - - # Tool request CM - tool_ctx = MagicMock() - tool_ctx.task_message = MagicMock() - tool_ctx.stream_update = AsyncMock() - tool_cm = _AsyncCtxManager(tool_ctx) - - mock_adk = _setup_activity_mocks( - mock_adk, - mock_create_hooks, - mock_client_cls, - mock_task_id, - mock_trace_id, - mock_parent_span_id, - messages=[ - AssistantMessage( - content=[ - TextBlock(text="Let me check"), - ToolUseBlock(id="tu-1", name="Read", input={"file": "foo.py"}), - ], - model="claude", - ), - ], - ) - # First call returns text CM, second returns tool CM - mock_adk.streaming.streaming_task_message_context.side_effect = [text_cm, tool_cm] - - await run_claude_agent_activity(prompt="Hi", workspace_path="/ws", allowed_tools=["Read"]) - - # Text CM was closed (via __aexit__) before tool streaming started - assert text_cm.exited is True - assert mock_adk.streaming.streaming_task_message_context.call_count == 2 - - @patch(_ACTIVITY_PATCHES[0]) - @patch(_ACTIVITY_PATCHES[1]) - @patch(_ACTIVITY_PATCHES[2]) - @patch(_ACTIVITY_PATCHES[3]) - @patch(_ACTIVITY_PATCHES[4]) - @patch(_ACTIVITY_PATCHES[5]) - async def test_result_message_captures_session_and_cost( - self, - mock_adk, - mock_create_hooks, - mock_client_cls, - mock_parent_span_id, - mock_trace_id, - mock_task_id, - ): - """ResultMessage should capture session_id, usage, and cost.""" - from agentex.lib.core.temporal.plugins.claude_agents.activities import run_claude_agent_activity - - _setup_activity_mocks( - mock_adk, - mock_create_hooks, - mock_client_cls, - mock_task_id, - mock_trace_id, - mock_parent_span_id, - messages=[ - ResultMessage( - subtype="result", - duration_ms=1500, - duration_api_ms=1200, - is_error=False, - num_turns=3, - session_id="sess-abc", - total_cost_usd=0.05, - usage={"input_tokens": 100, "output_tokens": 50}, - ), - ], - ) - - result = await run_claude_agent_activity(prompt="Hi", workspace_path="/ws", allowed_tools=["Read"]) - - assert result["session_id"] == "sess-abc" - assert result["cost_usd"] == 0.05 - assert result["usage"] == {"input_tokens": 100, "output_tokens": 50} - - @patch(_ACTIVITY_PATCHES[0]) - @patch(_ACTIVITY_PATCHES[1]) - @patch(_ACTIVITY_PATCHES[2]) - @patch(_ACTIVITY_PATCHES[3]) - @patch(_ACTIVITY_PATCHES[4]) - @patch(_ACTIVITY_PATCHES[5]) - async def test_system_init_message_captures_session_id( - self, - mock_adk, - mock_create_hooks, - mock_client_cls, - mock_parent_span_id, - mock_trace_id, - mock_task_id, - ): - """SystemMessage with subtype 'init' should capture session_id.""" - from agentex.lib.core.temporal.plugins.claude_agents.activities import run_claude_agent_activity - - _setup_activity_mocks( - mock_adk, - mock_create_hooks, - mock_client_cls, - mock_task_id, - mock_trace_id, - mock_parent_span_id, - messages=[ - SystemMessage(subtype="init", data={"session_id": "sess-init-123"}), - ], - ) - - result = await run_claude_agent_activity(prompt="Hi", workspace_path="/ws", allowed_tools=["Read"]) - - assert result["session_id"] == "sess-init-123" - - @patch(_ACTIVITY_PATCHES[0]) - @patch(_ACTIVITY_PATCHES[1]) - @patch(_ACTIVITY_PATCHES[2]) - @patch(_ACTIVITY_PATCHES[3]) - @patch(_ACTIVITY_PATCHES[4]) - @patch(_ACTIVITY_PATCHES[5]) - async def test_exception_cleans_up_text_stream_and_subagent_spans( - self, - mock_adk, - mock_create_hooks, - mock_client_cls, - mock_parent_span_id, - mock_trace_id, - mock_task_id, - ): - """On exception, both text stream and subagent spans should be cleaned up.""" - from agentex.lib.core.temporal.plugins.claude_agents.activities import run_claude_agent_activity - - mock_adk = _setup_activity_mocks( - mock_adk, - mock_create_hooks, - mock_client_cls, - mock_task_id, - mock_trace_id, - mock_parent_span_id, - messages=[], - ) - - # Make the client raise after entering - mock_client = AsyncMock() - mock_client.receive_response = MagicMock(side_effect=RuntimeError("connection lost")) - mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) - - import pytest - - with pytest.raises(RuntimeError, match="connection lost"): - await run_claude_agent_activity(prompt="Hi", workspace_path="/ws", allowed_tools=["Read"]) - - @patch(_ACTIVITY_PATCHES[0]) - @patch(_ACTIVITY_PATCHES[1]) - @patch(_ACTIVITY_PATCHES[2]) - @patch(_ACTIVITY_PATCHES[3]) - @patch(_ACTIVITY_PATCHES[4]) - @patch(_ACTIVITY_PATCHES[5]) - async def test_empty_text_block_not_serialized( - self, - mock_adk, - mock_create_hooks, - mock_client_cls, - mock_parent_span_id, - mock_trace_id, - mock_task_id, - ): - """TextBlock with empty text should not appear in serialized messages.""" - from agentex.lib.core.temporal.plugins.claude_agents.activities import run_claude_agent_activity - - _setup_activity_mocks( - mock_adk, - mock_create_hooks, - mock_client_cls, - mock_task_id, - mock_trace_id, - mock_parent_span_id, - messages=[AssistantMessage(content=[TextBlock(text="")], model="claude")], - ) - - result = await run_claude_agent_activity(prompt="Hi", workspace_path="/ws", allowed_tools=["Read"]) - - assert result["messages"] == [] - - -class AsyncIteratorMock: - """Helper to mock an async iterator (for client.receive_response()).""" - - def __init__(self, items): - self._items = iter(items) - - def __aiter__(self): - return self - - async def __anext__(self): - try: - return next(self._items) - except StopIteration: - raise StopAsyncIteration from None diff --git a/tests/lib/test_claude_agents_hooks.py b/tests/lib/test_claude_agents_hooks.py deleted file mode 100644 index ec82b16db..000000000 --- a/tests/lib/test_claude_agents_hooks.py +++ /dev/null @@ -1,398 +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 -_created_pkg_stubs: list[str] = [] -for _pkg in [ - "agentex", - "agentex.lib", - "agentex.lib.core", - "agentex.lib.core.temporal", - "agentex.lib.core.temporal.plugins", - "agentex.lib.core.temporal.plugins.claude_agents", - "agentex.lib.core.temporal.plugins.claude_agents.hooks", -]: - if _pkg not in sys.modules: - _mod = ModuleType(_pkg) - _mod.__path__ = [] # type: ignore[attr-defined] - _mod.__package__ = _pkg - sys.modules[_pkg] = _mod - _created_pkg_stubs.append(_pkg) - -# Real pydantic types โ€” importable without triggering the problematic chain. -from agentex.types.tool_response_content import ToolResponseContent # noqa: E402 - -# Load the hooks module directly -_spec = importlib.util.spec_from_file_location( - "agentex.lib.core.temporal.plugins.claude_agents.hooks.hooks", - _HOOKS_PATH, -) -assert _spec is not None and _spec.loader is not None -_hooks_mod = importlib.util.module_from_spec(_spec) -sys.modules[_spec.name] = _hooks_mod -_spec.loader.exec_module(_hooks_mod) - -# Drop the placeholder packages now that the module is loaded: their empty -# __path__ would otherwise block real imports of agentex.* submodules in every -# test collected after this file. The loaded module keeps its bindings. -for _pkg in _created_pkg_stubs: - sys.modules.pop(_pkg, None) - -TemporalStreamingHooks = _hooks_mod.TemporalStreamingHooks -create_streaming_hooks = _hooks_mod.create_streaming_hooks - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -class _AsyncCtxManager: - """Simple async context manager that yields a given value.""" - - def __init__(self, value): - self.value = value - - async def __aenter__(self): - return self.value - - async def __aexit__(self, *args): - pass - - -def _make_adk_mock(): - """Create a fresh adk mock with streaming context manager wired up. - - Returns (adk_mock, tool_ctx) where tool_ctx is the mock yielded by - `async with adk.streaming.streaming_task_message_context(...)`. - """ - tool_ctx = AsyncMock() - tool_ctx.task_message = MagicMock(name="task_message") - tool_ctx.stream_update = AsyncMock() - - adk_mock = MagicMock() - adk_mock.streaming.streaming_task_message_context.return_value = _AsyncCtxManager(tool_ctx) - return adk_mock, tool_ctx - - -def _make_post_tool_input(tool_name: str, tool_use_id: str, tool_response: str = "") -> dict: - """Build a HookInput dict for PostToolUse.""" - return { - "hook_event_name": "PostToolUse", - "tool_name": tool_name, - "tool_use_id": tool_use_id, - "tool_response": tool_response, - } - - -def _make_failure_input(tool_name: str, tool_use_id: str, error: str) -> dict: - """Build a HookInput dict for PostToolUseFailure.""" - return { - "hook_event_name": "PostToolUseFailure", - "tool_name": tool_name, - "tool_use_id": tool_use_id, - "error": error, - } - - -# --------------------------------------------------------------------------- -# Tests: TemporalStreamingHooks.__init__ -# --------------------------------------------------------------------------- - - -class TestTemporalStreamingHooksInit: - def test_stores_task_id(self): - hooks = TemporalStreamingHooks(task_id="task-1") - assert hooks.task_id == "task-1" - - def test_stores_trace_and_parent_span(self): - hooks = TemporalStreamingHooks(task_id="task-1", trace_id="trace-1", parent_span_id="span-1") - assert hooks.trace_id == "trace-1" - assert hooks.parent_span_id == "span-1" - - def test_defaults_to_none(self): - hooks = TemporalStreamingHooks(task_id=None) - assert hooks.task_id is None - assert hooks.trace_id is None - assert hooks.parent_span_id is None - - def test_subagent_spans_initialized_empty(self): - hooks = TemporalStreamingHooks(task_id="task-1") - assert hooks.subagent_spans == {} - - def test_accepts_shared_subagent_spans(self): - shared = {"tu-1": ("ctx", "span")} - hooks = TemporalStreamingHooks(task_id="task-1", subagent_spans=shared) - assert hooks.subagent_spans is shared - - -# --------------------------------------------------------------------------- -# Tests: auto_allow_hook (PreToolUse) -# --------------------------------------------------------------------------- - - -class TestAutoAllowHook: - @pytest.mark.asyncio - async def test_returns_allow_decision(self): - hooks = TemporalStreamingHooks(task_id="task-1") - result = await hooks.auto_allow_hook( - _input_data={}, - _tool_use_id="tu-1", - _context=None, - ) - assert result["continue_"] is True - assert result["hookSpecificOutput"]["permissionDecision"] == "allow" - - @pytest.mark.asyncio - async def test_works_without_task_id(self): - hooks = TemporalStreamingHooks(task_id=None) - result = await hooks.auto_allow_hook( - _input_data={}, - _tool_use_id=None, - _context=None, - ) - assert result["continue_"] is True - - -# --------------------------------------------------------------------------- -# Tests: post_tool_use_hook (PostToolUse) -# --------------------------------------------------------------------------- - - -class TestPostToolUseHook: - @pytest.mark.asyncio - async def test_skips_wrong_event_name(self): - hooks = TemporalStreamingHooks(task_id="task-1") - result = await hooks.post_tool_use_hook( - input_data={"hook_event_name": "PreToolUse", "tool_name": "Read", "tool_use_id": "tu-1"}, - _tool_use_id="tu-1", - _context=None, - ) - assert result["continue_"] is True - - @pytest.mark.asyncio - async def test_skips_when_no_task_id(self): - hooks = TemporalStreamingHooks(task_id=None) - result = await hooks.post_tool_use_hook( - input_data=_make_post_tool_input("Read", "tu-1", "contents"), - _tool_use_id="tu-1", - _context=None, - ) - assert result["continue_"] is True - - @pytest.mark.asyncio - async def test_streams_tool_response(self): - adk_mock, _ = _make_adk_mock() - - hooks = TemporalStreamingHooks(task_id="task-1") - with patch.object(_hooks_mod, "adk", adk_mock): - result = await hooks.post_tool_use_hook( - input_data=_make_post_tool_input("Read", "tu-1", "file contents"), - _tool_use_id="tu-1", - _context=None, - ) - - assert result["continue_"] is True - adk_mock.streaming.streaming_task_message_context.assert_called_once() - call_kwargs = adk_mock.streaming.streaming_task_message_context.call_args.kwargs - assert call_kwargs["task_id"] == "task-1" - assert isinstance(call_kwargs["initial_content"], ToolResponseContent) - assert call_kwargs["initial_content"].name == "Read" - assert call_kwargs["initial_content"].content == "file contents" - assert call_kwargs["initial_content"].tool_call_id == "tu-1" - - @pytest.mark.asyncio - async def test_closes_subagent_span(self): - adk_mock, _ = _make_adk_mock() - - span_mock = MagicMock() - span_ctx = AsyncMock() - span_ctx.__aexit__ = AsyncMock(return_value=False) - - subagent_spans = {"tu-sub-1": (span_ctx, span_mock)} - hooks = TemporalStreamingHooks(task_id="task-1", subagent_spans=subagent_spans) - - with patch.object(_hooks_mod, "adk", adk_mock): - await hooks.post_tool_use_hook( - input_data=_make_post_tool_input("Task", "tu-sub-1", "result"), - _tool_use_id="tu-sub-1", - _context=None, - ) - - assert span_mock.output == {"result": "result"} - span_ctx.__aexit__.assert_awaited_once_with(None, None, None) - assert "tu-sub-1" not in subagent_spans - - @pytest.mark.asyncio - async def test_streaming_failure_does_not_raise(self): - adk_mock = MagicMock() - adk_mock.streaming.streaming_task_message_context.side_effect = RuntimeError("down") - - hooks = TemporalStreamingHooks(task_id="task-1") - with patch.object(_hooks_mod, "adk", adk_mock): - result = await hooks.post_tool_use_hook( - input_data=_make_post_tool_input("Bash", "tu-1", "output"), - _tool_use_id="tu-1", - _context=None, - ) - assert result["continue_"] is True - - -# --------------------------------------------------------------------------- -# Tests: post_tool_use_failure_hook (PostToolUseFailure) -# --------------------------------------------------------------------------- - - -class TestPostToolUseFailureHook: - @pytest.mark.asyncio - async def test_skips_wrong_event_name(self): - hooks = TemporalStreamingHooks(task_id="task-1") - result = await hooks.post_tool_use_failure_hook( - input_data={"hook_event_name": "PostToolUse", "tool_name": "Read", "tool_use_id": "tu-1"}, - _tool_use_id="tu-1", - _context=None, - ) - assert result["continue_"] is True - - @pytest.mark.asyncio - async def test_skips_when_no_task_id(self): - hooks = TemporalStreamingHooks(task_id=None) - result = await hooks.post_tool_use_failure_hook( - input_data=_make_failure_input("Read", "tu-1", "permission denied"), - _tool_use_id="tu-1", - _context=None, - ) - assert result["continue_"] is True - - @pytest.mark.asyncio - async def test_streams_error_response(self): - adk_mock, _ = _make_adk_mock() - - hooks = TemporalStreamingHooks(task_id="task-1") - with patch.object(_hooks_mod, "adk", adk_mock): - result = await hooks.post_tool_use_failure_hook( - input_data=_make_failure_input("Bash", "tu-1", "command not found"), - _tool_use_id="tu-1", - _context=None, - ) - - assert result["continue_"] is True - adk_mock.streaming.streaming_task_message_context.assert_called_once() - call_kwargs = adk_mock.streaming.streaming_task_message_context.call_args.kwargs - assert call_kwargs["task_id"] == "task-1" - assert isinstance(call_kwargs["initial_content"], ToolResponseContent) - assert call_kwargs["initial_content"].name == "Bash" - assert call_kwargs["initial_content"].content == "Error: command not found" - assert call_kwargs["initial_content"].tool_call_id == "tu-1" - - @pytest.mark.asyncio - async def test_closes_subagent_span_on_failure(self): - adk_mock, _ = _make_adk_mock() - - span_mock = MagicMock() - span_ctx = AsyncMock() - span_ctx.__aexit__ = AsyncMock(return_value=False) - - subagent_spans = {"tu-sub-1": (span_ctx, span_mock)} - hooks = TemporalStreamingHooks(task_id="task-1", subagent_spans=subagent_spans) - - with patch.object(_hooks_mod, "adk", adk_mock): - await hooks.post_tool_use_failure_hook( - input_data=_make_failure_input("Task", "tu-sub-1", "timeout"), - _tool_use_id="tu-sub-1", - _context=None, - ) - - assert span_mock.output == {"error": "timeout"} - span_ctx.__aexit__.assert_awaited_once_with(None, None, None) - assert "tu-sub-1" not in subagent_spans - - @pytest.mark.asyncio - async def test_streaming_failure_does_not_raise(self): - adk_mock = MagicMock() - adk_mock.streaming.streaming_task_message_context.side_effect = RuntimeError("down") - - hooks = TemporalStreamingHooks(task_id="task-1") - with patch.object(_hooks_mod, "adk", adk_mock): - result = await hooks.post_tool_use_failure_hook( - input_data=_make_failure_input("Bash", "tu-1", "oops"), - _tool_use_id="tu-1", - _context=None, - ) - assert result["continue_"] is True - - -# --------------------------------------------------------------------------- -# Tests: create_streaming_hooks factory -# --------------------------------------------------------------------------- - - -class TestCreateStreamingHooks: - def test_returns_all_three_hook_events(self): - result = create_streaming_hooks(task_id="task-1") - assert "PreToolUse" in result - assert "PostToolUse" in result - assert "PostToolUseFailure" in result - - def test_each_key_has_one_hook_matcher(self): - result = create_streaming_hooks(task_id="task-1") - for key in ("PreToolUse", "PostToolUse", "PostToolUseFailure"): - assert len(result[key]) == 1 - assert isinstance(result[key][0], HookMatcher) - - def test_matchers_match_all_tools(self): - result = create_streaming_hooks(task_id="task-1") - for key in ("PreToolUse", "PostToolUse", "PostToolUseFailure"): - assert result[key][0].matcher is None - - def test_hooks_are_callable(self): - result = create_streaming_hooks(task_id="task-1") - for key in ("PreToolUse", "PostToolUse", "PostToolUseFailure"): - assert len(result[key][0].hooks) == 1 - assert callable(result[key][0].hooks[0]) - - def test_all_hooks_share_same_instance(self): - result = create_streaming_hooks(task_id="task-1", trace_id="trace-1", parent_span_id="span-1") - instances = {result[key][0].hooks[0].__self__ for key in ("PreToolUse", "PostToolUse", "PostToolUseFailure")} - assert len(instances) == 1 - instance = instances.pop() - assert instance.task_id == "task-1" - assert instance.trace_id == "trace-1" - assert instance.parent_span_id == "span-1" - - def test_passes_shared_subagent_spans(self): - shared = {} - result = create_streaming_hooks(task_id="task-1", subagent_spans=shared) - instance = result["PreToolUse"][0].hooks[0].__self__ - assert instance.subagent_spans is shared - - def test_none_task_id_still_creates_hooks(self): - result = create_streaming_hooks(task_id=None) - assert "PreToolUse" in result diff --git a/tests/lib/test_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_state_machine.py b/tests/lib/test_state_machine.py deleted file mode 100644 index ce32ba9f0..000000000 --- a/tests/lib/test_state_machine.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import annotations - -from typing import override -from unittest.mock import AsyncMock, patch - -from agentex.lib.sdk.state_machine import State, StateMachine, StateWorkflow -from agentex.lib.utils.model_utils import BaseModel - - -class ExampleData(BaseModel): - value: int = 0 - - -class InitialWorkflow(StateWorkflow): - transitions = ["next"] - - @override - async def execute(self, state_machine, state_machine_data=None): - return "next" - - -class NextWorkflow(StateWorkflow): - transitions = ["initial"] - - @override - async def execute(self, state_machine, state_machine_data=None): - return "initial" - - -class ExampleStateMachine(StateMachine[ExampleData]): - @override - async def terminal_condition(self): - return False - - -def _make_state_machine() -> ExampleStateMachine: - return ExampleStateMachine( - initial_state="initial", - states=[ - State(name="initial", workflow=InitialWorkflow()), - State(name="next", workflow=NextWorkflow()), - ], - task_id="task-123", - state_machine_data=ExampleData(value=1), - trace_transitions=True, - ) - - -async def test_reset_to_initial_state_skips_end_span_when_start_span_fails_open(): - state_machine = _make_state_machine() - await state_machine.transition("next") - - with patch( - "agentex.lib.sdk.state_machine.state_machine.adk.tracing.start_span", - new=AsyncMock(return_value=None), - ) as start_span, patch( - "agentex.lib.sdk.state_machine.state_machine.adk.tracing.end_span", - new=AsyncMock(), - ) as end_span: - await state_machine.reset_to_initial_state() - - assert state_machine.get_current_state() == "initial" - start_span.assert_awaited_once_with( - trace_id="task-123", - name="state_transition_reset", - input={"input_state": "next"}, - ) - end_span.assert_not_awaited() diff --git a/tests/lib/test_temporal_utils.py b/tests/lib/test_temporal_utils.py 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/lib/test_version_guard.py b/tests/lib/test_version_guard.py deleted file mode 100644 index 45bd2be7a..000000000 --- a/tests/lib/test_version_guard.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Tests for the agentex-client compatibility guard (0.13.0 split regression).""" - -from __future__ import annotations - -import pytest - -import agentex.lib._version_guard as guard - - -def test_passes_when_surface_present() -> None: - guard.verify_client_compatibility() # full client surface installed - - -def test_newer_client_not_rejected(monkeypatch: pytest.MonkeyPatch) -> None: - # Version is not a gate: a newer client (additive) with the full surface passes. - monkeypatch.setattr(guard, "version", lambda pkg: "0.14.0" if pkg == "agentex-client" else "0.13.0") - guard.verify_client_compatibility() - - -def test_raises_when_client_surface_incomplete(monkeypatch: pytest.MonkeyPatch) -> None: - import agentex.types - - # A partial install missing a needed symbol fails with an actionable error. - monkeypatch.delattr(agentex.types, "Event", raising=False) - with pytest.raises(ImportError, match="could not import the agentex-client REST surface"): - guard.verify_client_compatibility() diff --git a/tests/lib/test_webhooks.py b/tests/lib/test_webhooks.py deleted file mode 100644 index e42fac9dd..000000000 --- a/tests/lib/test_webhooks.py +++ /dev/null @@ -1,267 +0,0 @@ -"""Unit tests for the SDK webhook helper (agentex.lib.sdk.utils.webhooks).""" - -from __future__ import annotations - -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest - -from agentex.lib import adk -from agentex.lib.sdk.utils.webhooks import ( - WebhookError, - session_key, - handle_webhook, - render_generic, - shape_github_pr, - resolve_remote_params, -) - - -def _pr_payload(**pr_overrides) -> dict: - pr = { - "number": 42, - "title": "Add retry to uploader", - "body": "Adds backoff on 503.", - "html_url": "https://example.com/acme/widgets/pull/42", - } - pr.update(pr_overrides) - return { - "action": "opened", - "repository": {"full_name": "acme/widgets"}, - "sender": {"login": "octocat"}, - "pull_request": pr, - } - - -class TestSessionKey: - def test_stable_and_folds_same_conversation(self): - a = session_key("agent-1", "github_pr", "acme/widgets#42") - b = session_key("agent-1", "github_pr", "acme/widgets#42") - assert a == b and a.startswith("wh-github_pr-") - - def test_differs_by_peer(self): - assert session_key("a", "github_pr", "r#1") != session_key("a", "github_pr", "r#2") - - -class TestShaping: - def test_render_generic_prefers_text_field(self): - assert render_generic({"text": "hello"}) == "hello" - - def test_render_generic_falls_back_to_json(self): - assert "zen" in render_generic({"zen": "be awesome"}) - - def test_render_generic_matches_keys_case_insensitively(self): - assert render_generic({"Message": "hi there"}) == "hi there" - - def test_render_generic_supports_broadened_keys(self): - assert render_generic({"description": "do the thing"}) == "do the thing" - - def test_github_pr_shape(self): - text, peer, sender = shape_github_pr(_pr_payload()) - assert "Pull request acme/widgets#42: Add retry to uploader" in text - assert "Action: opened" in text - assert "Adds backoff on 503." in text - assert peer == "acme/widgets#42" - assert sender == "octocat" - - def test_github_pr_includes_diff(self): - body = _pr_payload() - body["pull_request"]["diff"] = "diff --git a/x b/x\n+line" - text, _, _ = shape_github_pr(body) - assert "Diff:" in text and "+line" in text - - def test_non_pr_payload_falls_back_to_generic(self): - text, peer, _ = shape_github_pr({"zen": "be awesome", "hook_id": 1}) - assert "Pull request" not in text - assert "be awesome" in text - assert peer is None - - -class TestResolveRemoteParams: - async def test_envelope_with_params_and_metadata(self): - async def fetch(_url): - return {"params": {"system_prompt": "x", "model": "m"}, "task_metadata": {"cfg": "1"}} - - params, md = await resolve_remote_params("https://h/resolve", fetch=fetch) - assert params == {"system_prompt": "x", "model": "m"} - assert md == {"cfg": "1"} - - async def test_bare_object_is_params_minus_task_metadata(self): - async def fetch(_url): - return {"system_prompt": "x", "task_metadata": {"cfg": "1"}} - - params, md = await resolve_remote_params("https://h/resolve", fetch=fetch) - assert params == {"system_prompt": "x"} # task_metadata stripped from params - assert md == {"cfg": "1"} - - async def test_non_object_raises(self): - async def fetch(_url): - return ["nope"] - - with pytest.raises(WebhookError): - await resolve_remote_params("https://h/resolve", fetch=fetch) - - -def _agent_msg(text: str): - return SimpleNamespace(content=SimpleNamespace(author="agent", type="text", content=text)) - - -class TestHandleWebhook: - @pytest.fixture(autouse=True) - def _mock_adk(self, monkeypatch): - self.created = {} - self.sent = {} - self.stamped = {} - self.created_task_metadata = {} - - async def create_task(*, name, agent_name, params=None, request=None, **_): - self.created = {"name": name, "agent_name": agent_name, "params": params, "request": request} - return SimpleNamespace(id="task-1", task_metadata=self.created_task_metadata) - - async def send_message(*, task_id, agent_name, content, **_): - self.sent = {"task_id": task_id, "content": content} - return [_agent_msg("Looks good โ€” ship it.")] - - async def update_task(*, task_id, task_metadata=None, **_): - self.stamped = {"task_id": task_id, "task_metadata": task_metadata} - return SimpleNamespace(id=task_id) - - send_event = AsyncMock() - monkeypatch.setattr(adk.acp, "create_task", create_task) - monkeypatch.setattr(adk.acp, "send_message", send_message) - monkeypatch.setattr(adk.acp, "send_event", send_event) - monkeypatch.setattr(adk.tasks, "update", update_task) - self.send_event = send_event - yield - - async def test_sync_github_pr_with_config_by_id(self): - async def fake_resolve(_url): - return {"params": {"system_prompt": "review"}, "task_metadata": {"agent_config_id": "cfg-9"}} - - result = await handle_webhook( - agent_name="golden-agent", - payload=_pr_payload(), - acp_type="sync", - shaper="github_pr", - params_source="https://h/v5/agent_configs/cfg-9/resolve", - fetch=fake_resolve, - ) - - assert result.reply == "Looks good โ€” ship it." - assert self.created["params"] == {"system_prompt": "review"} - # metadata is returned on the result (SDK task/create can't carry it) - md = result.task_metadata - assert md["channel"] == "github_pr" - assert md["peer_id"] == "acme/widgets#42" - assert md["agent_config_id"] == "cfg-9" - # task folded on a stable session key - assert self.created["name"].startswith("wh-github_pr-") - # metadata is also stamped onto the task (best-effort) so it's labeled in the UI - assert self.stamped["task_id"] == "task-1" - assert self.stamped["task_metadata"]["peer_id"] == "acme/widgets#42" - assert self.stamped["task_metadata"]["agent_config_id"] == "cfg-9" - - async def test_inline_params_no_fetch(self): - result = await handle_webhook( - agent_name="a", - payload={"text": "hi"}, - acp_type="sync", - params={"system_prompt": "inline"}, - ) - assert result.reply == "Looks good โ€” ship it." - assert self.created["params"] == {"system_prompt": "inline"} - - async def test_source_metadata_cannot_override_canonical(self): - async def fake_resolve(_url): - return {"params": {}, "task_metadata": {"channel": "spoofed"}} - - result = await handle_webhook( - agent_name="a", - payload=_pr_payload(), - shaper="github_pr", - params_source="https://h/resolve", - fetch=fake_resolve, - ) - assert result.task_metadata["channel"] == "github_pr" - - async def test_task_metadata_preserves_existing_keys_on_reused_task(self): - self.created_task_metadata = { - "labels": ["customer-facing"], - "agent_config_id": "old-cfg", - "channel": "old-channel", - } - - async def fake_resolve(_url): - return {"params": {}, "task_metadata": {"agent_config_id": "cfg-9"}} - - await handle_webhook( - agent_name="a", - payload=_pr_payload(), - shaper="github_pr", - params_source="https://h/resolve", - fetch=fake_resolve, - ) - - stamped_metadata = self.stamped["task_metadata"] - assert stamped_metadata["labels"] == ["customer-facing"] - assert stamped_metadata["agent_config_id"] == "cfg-9" - assert stamped_metadata["channel"] == "github_pr" - - async def test_async_without_wait_sends_event_and_returns_no_reply(self): - result = await handle_webhook(agent_name="a", payload={"text": "go"}, acp_type="async", wait=False) - assert result.reply is None - self.send_event.assert_awaited_once() - - -class TestAwaitReplyIgnoresStalePriorReply: - async def test_returns_only_new_agent_text_on_reused_task(self, monkeypatch): - from agentex.lib.sdk.utils.webhooks import _await_reply - - old = _agent_msg("OLD reply") - old.id = "m1" - new = _agent_msg("NEW reply") - new.id = "m2" - calls = {"n": 0} - - async def fake_list(*, task_id, **_): - calls["n"] += 1 - return [old] if calls["n"] < 2 else [old, new] # new appears on 2nd poll - - async def no_sleep(_seconds): - return None - - monkeypatch.setattr(adk.messages, "list", fake_list) - monkeypatch.setattr("asyncio.sleep", no_sleep) - - # baseline = the pre-existing old message; only m2 (NEW) should be returned - reply = await _await_reply("task-1", {"m1"}, interval_s=0.0, quiescence_s=0.0) - assert reply == "NEW reply" - - async def test_returns_idless_agent_text_after_snapshot(self, monkeypatch): - from agentex.lib.sdk.utils.webhooks import _await_reply - - old = _agent_msg("OLD reply") - old.id = None - new = _agent_msg("NEW reply") - new.id = None - calls = {"n": 0} - - async def fake_list(*, task_id, **_): - calls["n"] += 1 - return [old] if calls["n"] < 2 else [old, new] - - async def no_sleep(_seconds): - return None - - monkeypatch.setattr(adk.messages, "list", fake_list) - monkeypatch.setattr("asyncio.sleep", no_sleep) - - reply = await _await_reply( - "task-1", - set(), - seen_count=1, - interval_s=0.0, - quiescence_s=0.0, - ) - assert reply == "NEW reply" diff --git a/tests/lib/utils/__init__.py b/tests/lib/utils/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/lib/utils/test_completions.py b/tests/lib/utils/test_completions.py deleted file mode 100644 index 3aa5f9120..000000000 --- a/tests/lib/utils/test_completions.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -from agentex.lib.utils.completions import concat_completion_chunks -from agentex.lib.types.llm_messages import Delta, Usage, Choice, Completion - - -def _delta_chunk(content: str, role: str | None = None) -> Completion: - return Completion(choices=[Choice(index=0, delta=Delta(content=content, role=role))]) - - -def _usage_only_chunk(prompt: int, completion: int) -> Completion: - # stream_options.include_usage: litellm/OpenAI send a final chunk that has - # usage but an empty choices list - return Completion( - choices=[], - usage=Usage(prompt_tokens=prompt, completion_tokens=completion, total_tokens=prompt + completion), - ) - - -class TestConcatCompletionChunks: - def test_concatenates_delta_content(self): - result = concat_completion_chunks([_delta_chunk("Hel", role="assistant"), _delta_chunk("lo!")]) - - assert result.choices[0].message.content == "Hello!" - - def test_trailing_usage_only_chunk_keeps_choices_and_usage(self): - result = concat_completion_chunks( - [_delta_chunk("Hel", role="assistant"), _delta_chunk("lo!"), _usage_only_chunk(10, 5)] - ) - - assert result.choices[0].message.content == "Hello!" - assert result.usage is not None - assert result.usage.prompt_tokens == 10 - assert result.usage.completion_tokens == 5 - assert result.usage.total_tokens == 15 - - def test_usage_summed_across_chunks(self): - chunk_a = _delta_chunk("a", role="assistant") - chunk_a.usage = Usage(prompt_tokens=1, completion_tokens=2, total_tokens=3) - chunk_b = _delta_chunk("b") - chunk_b.usage = Usage(prompt_tokens=4, completion_tokens=5, total_tokens=9) - - result = concat_completion_chunks([chunk_a, chunk_b]) - - assert result.usage.prompt_tokens == 5 - assert result.usage.completion_tokens == 7 - assert result.usage.total_tokens == 12 - - def test_no_usage_chunks_leave_usage_none(self): - result = concat_completion_chunks([_delta_chunk("x", role="assistant")]) - - assert result.usage is None diff --git a/tests/test_acp_interrupt.py b/tests/test_acp_interrupt.py deleted file mode 100644 index f53cfa6b4..000000000 --- a/tests/test_acp_interrupt.py +++ /dev/null @@ -1,178 +0,0 @@ -"""Unit tests for the hand-editable task/interrupt additions. - -Covers the regeneration-safe surfaces added for the interrupt-and-queue design -(design doc sections 6, 7, 9.2): - -1. Protocol (``agentex.protocol.acp``): ``RPCMethod.TASK_INTERRUPT``, the - ``InterruptTaskParams`` model (mirror of ``CancelTaskParams``), and the - ``PARAMS_MODEL_BY_METHOD`` entry, plus the back-compat shim re-export. -2. ACP server routing: ``BaseACPServer.on_task_interrupt`` registers a handler - under ``RPCMethod.TASK_INTERRUPT``. -3. Temporal transport: ``BaseWorkflow.on_interrupt`` is a ``@workflow.signal`` - named ``interrupt_turn`` (and is NOT abstract, so existing agents keep - working), and ``TemporalTaskService.interrupt`` forwards that signal without - tearing the workflow down. -""" - -from __future__ import annotations - -from unittest.mock import Mock, AsyncMock - -import pytest - -from agentex.types.task import Task -from agentex.types.agent import Agent -from agentex.protocol.acp import ( - PARAMS_MODEL_BY_METHOD, - RPCMethod, - CancelTaskParams, - InterruptTaskParams, -) - - -def _agent() -> Agent: - return Agent( - id="test-agent-456", - name="test-agent", - description="test-agent", - acp_type="async", - created_at="2023-01-01T00:00:00Z", - updated_at="2023-01-01T00:00:00Z", - ) - - -def _task() -> Task: - return Task(id="test-task-123", status="RUNNING") - - -# --------------------------------------------------------------------------- -# Protocol additions -# --------------------------------------------------------------------------- - - -class TestInterruptProtocol: - def test_rpc_method_value(self) -> None: - assert RPCMethod.TASK_INTERRUPT.value == "task/interrupt" - # Constructible from the wire string (the ACP server does RPCMethod(str)). - assert RPCMethod("task/interrupt") is RPCMethod.TASK_INTERRUPT - - def test_params_model_registered(self) -> None: - assert PARAMS_MODEL_BY_METHOD[RPCMethod.TASK_INTERRUPT] is InterruptTaskParams - - def test_params_mirror_cancel_shape(self) -> None: - """InterruptTaskParams mirrors CancelTaskParams field-for-field.""" - assert set(InterruptTaskParams.model_fields) == set(CancelTaskParams.model_fields) - assert set(InterruptTaskParams.model_fields) == {"agent", "task", "request"} - - def test_params_validate_round_trip(self) -> None: - params = InterruptTaskParams(agent=_agent(), task=_task()) - assert params.task.id == "test-task-123" - assert params.request is None - # Header forwarding path (BaseACPServer populates params.request). - with_headers = InterruptTaskParams.model_validate( - { - "agent": _agent().model_dump(), - "task": _task().model_dump(), - "request": {"headers": {"x-foo": "bar"}}, - } - ) - assert with_headers.request == {"headers": {"x-foo": "bar"}} - - def test_shim_reexports_interrupt_params(self) -> None: - """The back-compat shim must re-export the new model as the same object.""" - from agentex.protocol import acp as canon - from agentex.lib.types import acp as shim - - assert shim.InterruptTaskParams is canon.InterruptTaskParams - - -# --------------------------------------------------------------------------- -# ACP server routing -# --------------------------------------------------------------------------- - - -class TestACPServerInterruptRouting: - def test_on_task_interrupt_registers_handler(self) -> None: - from unittest.mock import patch - - from agentex.lib.sdk.fastacp.base.base_acp_server import BaseACPServer - - with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): - server = BaseACPServer() - - assert RPCMethod.TASK_INTERRUPT not in server._handlers - - @server.on_task_interrupt - async def _handle(params: InterruptTaskParams) -> None: # noqa: ARG001 - return None - - assert RPCMethod.TASK_INTERRUPT in server._handlers - assert server._handlers[RPCMethod.TASK_INTERRUPT] is not None - - def test_temporal_acp_wires_interrupt_handler(self) -> None: - from unittest.mock import patch - - from agentex.lib.sdk.fastacp.impl.temporal_acp import TemporalACP - - with patch.dict("os.environ", {"AGENTEX_BASE_URL": ""}): - server = TemporalACP.create(temporal_address="localhost:7233") - - assert RPCMethod.TASK_INTERRUPT in server._handlers - - -# --------------------------------------------------------------------------- -# Temporal transport: signal + service forwarding -# --------------------------------------------------------------------------- - - -class TestWorkflowInterruptSignal: - def test_on_interrupt_is_signal_named_interrupt_turn(self) -> None: - from agentex.lib.core.temporal.types.workflow import SignalName - from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow - - # getattr avoids the dunder name-mangling that would otherwise rewrite - # this to _TestWorkflowInterruptSignal__temporal_signal_definition. - sd = getattr(BaseWorkflow.on_interrupt, "__temporal_signal_definition") - assert sd is not None - # str-enum: equal by value to the wire string "interrupt_turn". - assert sd.name == SignalName.INTERRUPT_TURN - assert sd.name == "interrupt_turn" - - def test_on_interrupt_not_abstract(self) -> None: - """A default no-op keeps existing (non-interruptible) workflows valid.""" - from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow - - assert "on_interrupt" not in BaseWorkflow.__abstractmethods__ - - -class TestTemporalTaskServiceInterrupt: - async def test_interrupt_sends_signal_not_cancel(self) -> None: - from agentex.lib.core.temporal.types.workflow import SignalName - from agentex.lib.core.temporal.services.temporal_task_service import ( - TemporalTaskService, - ) - - temporal_client = Mock() - temporal_client.send_signal = AsyncMock() - temporal_client.cancel_workflow = AsyncMock() - temporal_client.terminate_workflow = AsyncMock() - - service = TemporalTaskService(temporal_client=temporal_client, env_vars=Mock()) - - await service.interrupt(agent=_agent(), task=_task(), request={"headers": {"x-a": "b"}}) - - # Non-terminal: it signals, it does NOT cancel or terminate the workflow. - temporal_client.cancel_workflow.assert_not_called() - temporal_client.terminate_workflow.assert_not_called() - temporal_client.send_signal.assert_awaited_once() - - kwargs = temporal_client.send_signal.await_args.kwargs - assert kwargs["workflow_id"] == "test-task-123" - assert kwargs["signal"] == SignalName.INTERRUPT_TURN.value == "interrupt_turn" - # Payload is a serialized InterruptTaskParams (task/agent/request). - assert kwargs["payload"]["task"]["id"] == "test-task-123" - assert kwargs["payload"]["request"] == {"headers": {"x-a": "b"}} - - -if __name__ == "__main__": # pragma: no cover - raise SystemExit(pytest.main([__file__, "-v"])) diff --git a/tests/test_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_config_shims.py b/tests/test_config_shims.py deleted file mode 100644 index 846e173b1..000000000 --- a/tests/test_config_shims.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Tests that pin the back-compat contract for config-model shims. - -The canonical location for deployment/agent configuration models is -:mod:`agentex.config` (mirroring PR scaleapi/scale-agentex-python#371, which -did this for protocol types). The historical locations under -:mod:`agentex.lib.sdk.config.*` and :mod:`agentex.lib.types.*` are preserved as -re-export shims so external consumers' existing imports continue to work. - -These tests enforce: - -1. **Symbol parity** โ€” every public name the original modules exported is - still importable from the old path. -2. **Identity** โ€” the class objects at the shim path are the *same* objects as - the canonical path, so ``isinstance`` stays correct across import styles. -3. **Config preservation** โ€” the swap from ``model_utils.BaseModel`` to plain - pydantic kept ``populate_by_name`` (``DeploymentConfig``'s ``global`` alias - relies on it; it previously also carried a now-removed - ``class Config: validate_by_name``). -""" - -from __future__ import annotations - - -def test_config_shims_re_export_all_original_symbols() -> None: - """Every name historically exported from the old paths must still be - importable from those paths via the back-compat shims.""" - from agentex.lib.types.credentials import CredentialMapping # noqa: F401 - from agentex.lib.types.agent_configs import ( # noqa: F401 - TemporalConfig, - TemporalWorkerConfig, - TemporalWorkflowConfig, - ) - from agentex.lib.sdk.config.agent_config import AgentConfig # noqa: F401 - from agentex.lib.sdk.config.build_config import ( # noqa: F401 - BuildConfig, - BuildContext, - ) - from agentex.lib.sdk.config.agent_manifest import ( # noqa: F401 - AgentManifest, - BuildContextManager, - load_agent_manifest, - build_context_manager, - ) - from agentex.lib.sdk.config.deployment_config import ( # noqa: F401 - ImageConfig, - ClusterConfig, - ResourceConfig, - DeploymentConfig, - AuthenticationConfig, - ResourceRequirements, - ImagePullSecretConfig, - InjectedSecretsValues, - GlobalDeploymentConfig, - InjectedImagePullSecretValues, - ) - from agentex.lib.sdk.config.environment_config import ( # noqa: F401 - AgentAuthConfig, - OciRegistryConfig, - AgentKubernetesConfig, - AgentEnvironmentConfig, - AgentEnvironmentsConfig, - load_environments_config, - load_environments_config_from_manifest_dir, - ) - from agentex.lib.sdk.config.local_development_config import ( # noqa: F401 - LocalAgentConfig, - LocalPathsConfig, - LocalDevelopmentConfig, - ) - - -def test_config_shim_classes_are_identical_to_canonical() -> None: - """Shim re-exports must be the *same* class objects as the canonical path. - Different objects would break ``isinstance`` for code that mixes import - styles.""" - from agentex.config import ( - credentials, - agent_config, - build_config, - agent_configs as canon_agent_configs, - agent_manifest as canon_manifest, - deployment_config as canon_deploy, - environment_config as canon_env, - local_development_config as canon_local, - ) - from agentex.lib.types import credentials as shim_creds, agent_configs as shim_agent_configs - from agentex.lib.sdk.config import ( - agent_config as shim_agent_config, - build_config as shim_build, - agent_manifest as shim_manifest, - deployment_config as shim_deploy, - environment_config as shim_env, - local_development_config as shim_local, - ) - - assert shim_agent_config.AgentConfig is agent_config.AgentConfig - assert shim_build.BuildConfig is build_config.BuildConfig - assert shim_deploy.DeploymentConfig is canon_deploy.DeploymentConfig - assert shim_local.LocalDevelopmentConfig is canon_local.LocalDevelopmentConfig - assert shim_env.AgentEnvironmentsConfig is canon_env.AgentEnvironmentsConfig - assert shim_env.AgentEnvironmentConfig is canon_env.AgentEnvironmentConfig - assert shim_agent_configs.TemporalConfig is canon_agent_configs.TemporalConfig - assert shim_creds.CredentialMapping is credentials.CredentialMapping - assert shim_manifest.AgentManifest is canon_manifest.AgentManifest - - -def test_deployment_config_populates_global_by_name_and_alias() -> None: - """``populate_by_name`` (inherited via ConfigBaseModel) must let the - ``global``-aliased field be set by either its field name or its alias โ€” - the swap dropped the legacy ``class Config: validate_by_name``.""" - from agentex.config.deployment_config import DeploymentConfig - - by_name = DeploymentConfig.model_validate({"image": {"repository": "r"}, "global_config": {"replicaCount": 2}}) - assert by_name.global_config.replicaCount == 2 - - by_alias = DeploymentConfig.model_validate({"image": {"repository": "r"}, "global": {"replicaCount": 3}}) - assert by_alias.global_config.replicaCount == 3 diff --git a/tests/test_function_tool.py b/tests/test_function_tool.py deleted file mode 100644 index 484ce8af2..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 agentex.lib.core.temporal.activities.adk.providers.openai_activities import ( - FunctionTool, -) - - -def sample_handler(context, args: str) -> str: - """Sample handler function for testing.""" - return f"Processed: {args}" - - -def complex_handler(context, args: str) -> dict[str, Any]: - """More complex handler that returns structured data.""" - parsed_args = json.loads(args) if args else {} - return { - "status": "success", - "input": parsed_args, - "context_info": str(type(context)), - } - - -class TestFunctionTool: - """Test cases for FunctionTool serialization with JSON.""" - - def test_basic_serialization_with_json(self): - """Test that FunctionTool can be serialized and deserialized with JSON.""" - # Create a FunctionTool with a callable - tool = FunctionTool( - name="test_tool", - description="A test tool", - params_json_schema={"type": "string"}, - strict_json_schema=True, - is_enabled=True, - on_invoke_tool=sample_handler, - ) - - # Serialize to JSON (this is what the caller will do) - json_data = json.dumps(tool.model_dump()) - - # Deserialize from JSON - data = json.loads(json_data) - new_tool = FunctionTool.model_validate(data) - - # Test that the callable is restored - assert new_tool.on_invoke_tool is not None - assert callable(new_tool.on_invoke_tool) - - # Test that the callable works as expected - result = new_tool.on_invoke_tool(None, "test_input") - assert result == "Processed: test_input" - - def test_complex_function_serialization(self): - """Test serialization of more complex functions.""" - tool = FunctionTool( - name="complex_tool", - description="A complex test tool", - params_json_schema={ - "type": "object", - "properties": {"key": {"type": "string"}}, - }, - on_invoke_tool=complex_handler, - ) - - # Serialize and deserialize via JSON - json_data = json.dumps(tool.model_dump()) - data = json.loads(json_data) - new_tool = FunctionTool.model_validate(data) - - # Test the complex function - test_input = '{"test": "value"}' - result = new_tool.on_invoke_tool(None, test_input) - - assert result["status"] == "success" - assert result["input"] == {"test": "value"} - - def test_none_callable_handling(self): - """Test that passing None for callable raises an error.""" - # Test that None callable raises ValueError - with pytest.raises( - ValueError, - match="One of `on_invoke_tool` or `on_invoke_tool_serialized` should be set", - ): - FunctionTool( - name="empty_tool", - description="Tool with no callable", - params_json_schema={"type": "string"}, - on_invoke_tool=None, - ) - - # Test with valid function - this should work - tool_func = FunctionTool( - name="func_tool", - description="Tool with function", - params_json_schema={"type": "string"}, - on_invoke_tool=sample_handler, - ) - assert tool_func.on_invoke_tool is not None - - def test_lambda_function_serialization(self): - """Test that lambda functions can be serialized.""" - # Set a lambda function - tool = FunctionTool( - name="lambda_tool", - description="Tool with lambda", - params_json_schema={"type": "string"}, - on_invoke_tool=lambda ctx, args: f"Lambda result: {args}", - ) - - # Serialize and deserialize via JSON - json_data = json.dumps(tool.model_dump()) - data = json.loads(json_data) - new_tool = FunctionTool.model_validate(data) - - # Test that the lambda works - result = new_tool.on_invoke_tool(None, "test") - assert result == "Lambda result: test" - - def test_closure_serialization(self): - """Test that closures can be serialized.""" - - def create_handler(prefix: str): - def handler(context, args: str) -> str: - return f"{prefix}: {args}" - - return handler - - # Set a closure - tool = FunctionTool( - name="closure_tool", - description="Tool with closure", - params_json_schema={"type": "string"}, - on_invoke_tool=create_handler("PREFIX"), - ) - - # Serialize and deserialize via JSON - json_data = json.dumps(tool.model_dump()) - data = json.loads(json_data) - new_tool = FunctionTool.model_validate(data) - - # Test that the closure works with captured variable - result = new_tool.on_invoke_tool(None, "test") - assert result == "PREFIX: test" - - def test_function_tool_with_none_handler_raises_error(self): - """Test that trying to create tool with None handler raises error.""" - # Test that None callable raises ValueError - with pytest.raises( - ValueError, - match="One of `on_invoke_tool` or `on_invoke_tool_serialized` should be set", - ): - FunctionTool( - name="none_handler_test", - description="Test tool with None handler", - params_json_schema={"type": "string"}, - on_invoke_tool=None, - ) - - def test_to_oai_function_tool_with_valid_handler(self): - """Test that to_oai_function_tool works with valid function.""" - tool = FunctionTool( - name="valid_handler_test", - description="Test tool with valid handler", - params_json_schema={"type": "string"}, - on_invoke_tool=sample_handler, - ) - - # This should work when on_invoke_tool is set - oai_tool = tool.to_oai_function_tool() - - # Verify the OAI tool was created successfully - assert oai_tool is not None - assert oai_tool.name == "valid_handler_test" - assert oai_tool.description == "Test tool with valid handler" - assert oai_tool.on_invoke_tool is not None - assert callable(oai_tool.on_invoke_tool) - - # Test that the handler works through the OAI tool - result = oai_tool.on_invoke_tool(None, "test_input") - assert result == "Processed: test_input" - - def test_serialization_error_handling(self): - """Test error handling when serialization fails.""" - - # Try to create a FunctionTool with an unserializable callable - class UnserializableCallable: - def __call__(self, context, args): - return "test" - - @override - def __getstate__(self): - raise Exception("Cannot serialize this object") - - unserializable = UnserializableCallable() - - # This should raise an Exception during construction (from the unserializable object) - with pytest.raises(Exception, match="Cannot serialize this object"): - FunctionTool( - name="error_test_with_unserializable", - description="Test error handling with unserializable", - params_json_schema={"type": "string"}, - on_invoke_tool=unserializable, - ) - - def test_deserialization_error_handling(self): - """Test error handling when deserialization fails.""" - - # Create a tool and manually corrupt its serialized data to test deserialization error - # First, create a valid tool - valid_tool = FunctionTool( - name="valid_tool", - description="Valid tool for corruption", - params_json_schema={"type": "string"}, - on_invoke_tool=sample_handler, - ) - - # Serialize it - serialized_data = valid_tool.model_dump() - - # Corrupt the serialized callable data with invalid base64 - serialized_data["on_invoke_tool_serialized"] = ( - "invalid_base64_data!" # Add invalid character - ) - - # This should raise an error during model validation due to invalid base64 - with pytest.raises((ValidationError, ValueError)): - FunctionTool.model_validate(serialized_data) - - def test_full_roundtrip_with_serialization(self): - """Test a full roundtrip with a single tool.""" - tool = FunctionTool( - name="test_tool", - description="Test tool for roundtrip", - params_json_schema={"type": "string"}, - on_invoke_tool=lambda ctx, args: f"Tool result: {args}", - ) - - # Serialize tool to JSON - json_data = json.dumps(tool.model_dump()) - - # Deserialize from JSON - data = json.loads(json_data) - new_tool = FunctionTool.model_validate(data) - - # Test the tool - result = new_tool.on_invoke_tool(None, "test") - assert "Tool result: test" == result - - result = new_tool.to_oai_function_tool().on_invoke_tool(None, "test") - assert "Tool result: test" == result diff --git a/tests/test_header_forwarding.py b/tests/test_header_forwarding.py 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/tests/test_version_guard.py b/tests/test_version_guard.py deleted file mode 100644 index dba4a50e2..000000000 --- a/tests/test_version_guard.py +++ /dev/null @@ -1,208 +0,0 @@ -"""Unit tests for the runtime backend version guard (agentex.lib.core.compat.version_guard).""" - -from __future__ import annotations - -import asyncio - -import httpx -import pytest - -from agentex.lib.core.compat import version_guard as vg - - -def _run(coro): - return asyncio.run(coro) - - -def _patch_transport(monkeypatch, handler): - """Make version_guard's httpx.AsyncClient route through an in-memory MockTransport, - so fetch_backend_version runs for real (request build, status check, JSON parse) - without touching the network. `handler(request) -> httpx.Response` (or raises).""" - - real_client = httpx.AsyncClient # capture before patching to avoid recursing into the factory - - def factory(**kwargs): - kwargs.pop("transport", None) - return real_client(transport=httpx.MockTransport(handler), **kwargs) - - monkeypatch.setattr(vg.httpx, "AsyncClient", factory) - - -def test_parse_versions(): - assert vg._parse("0.2.1") == (0, 2, 1, None) - assert vg._parse("v1.4.0") == (1, 4, 0, None) - assert vg._parse("0.2.1-rc.1+build5") == (0, 2, 1, "rc.1") # build metadata ignored - assert vg._parse("0.1.0+build5") == (0, 1, 0, None) # build metadata only, still stable - assert vg._parse("garbage") is None - assert vg._parse(None) is None - - -def test_parse_rejects_malformed_tails(): - # Anchored regex: a junk tail after the triplet must NOT silently parse as stable 0.1.0; - # it has to fall through to None (โ†’ unknown / unparseable path), not satisfy the floor. - for bad in ("0.1.0rc1", "0.1.0foo", "0.1.0.1", "0.1.0-", "1.2", "0.1.0-rc 1"): - assert vg._parse(bad) is None, bad - - -def test_parse_anchored_both_ends(): - # Leading anchor (^): anything before the triplet (other than whitespace / a `v`) is rejected. - for bad in ("foo0.1.0", ">=0.1.0", "x0.1.0", "=0.1.0", "0 0.1.0"): - assert vg._parse(bad) is None, bad - # Trailing anchor ($): anything after the version (other than whitespace) is rejected. - for bad in ("0.1.0 extra", "0.1.0;", "0.1.0/", "0.1.0+", "0.1.0 0.1.0"): - assert vg._parse(bad) is None, bad - # What the anchors DO permit: surrounding whitespace and an optional leading `v`. - assert vg._parse(" 0.1.0 ") == (0, 1, 0, None) - assert vg._parse("\tv1.2.3\n") == (1, 2, 3, None) - assert vg._parse(" 0.2.0-rc.1 ") == (0, 2, 0, "rc.1") - - -def test_prerelease_precedence(): - k = lambda v: vg._precedence_key(vg._parse(v)) # noqa: E731 - assert k("0.1.0-rc.1") < k("0.1.0") # prerelease precedes its stable release (SemVer ยง11) - assert k("0.1.0-rc.1") < k("0.1.0-rc.2") # numeric prerelease identifiers compare numerically - assert k("0.1.0-alpha") < k("0.1.0-rc") # numeric/alpha ordering by identifier - assert k("0.1.0") < k("0.1.1-rc.1") # patch bump outranks prior stable - assert k("0.2.0-rc.1") > k("0.1.0") # prerelease of a higher version still clears the floor - - -def test_compatible_backend_passes(monkeypatch): - async def fake(url, **kw): - return "0.2.0" - - monkeypatch.setattr(vg, "fetch_backend_version", fake) - # backend (0.2.0) >= min (0.1.0) โ†’ no raise - _run(vg.assert_backend_compatible("http://backend", min_version="0.1.0")) - - -def test_incompatible_backend_raises(monkeypatch): - async def fake(url, **kw): - return "0.0.9" - - monkeypatch.setattr(vg, "fetch_backend_version", fake) - with pytest.raises(vg.IncompatibleBackendError) as exc: - _run(vg.assert_backend_compatible("http://backend", min_version="0.1.0", sdk_version="0.13.0")) - msg = str(exc.value) - assert "0.13.0" in msg and "0.1.0" in msg and "0.0.9" in msg # actionable message - - -def test_prerelease_backend_below_stable_floor_raises(monkeypatch): - async def fake(url, **kw): - return "0.1.0-rc.1" # release candidate: precedes the stable 0.1.0 contract - - monkeypatch.setattr(vg, "fetch_backend_version", fake) - with pytest.raises(vg.IncompatibleBackendError): - _run(vg.assert_backend_compatible("http://backend", min_version="0.1.0", sdk_version="0.13.0")) - - -def test_skip_env_bypasses(monkeypatch): - async def fake(url, **kw): - raise AssertionError("must not fetch when skip env is set") - - monkeypatch.setattr(vg, "fetch_backend_version", fake) - monkeypatch.setenv(vg.SKIP_ENV, "1") - # even an impossible min must not raise when explicitly skipped - _run(vg.assert_backend_compatible("http://backend", min_version="9.9.9")) - - -def test_unknown_backend_version_does_not_crash(monkeypatch): - async def fake(url, **kw): - return None # unreachable / no version โ†’ unknown - - monkeypatch.setattr(vg, "fetch_backend_version", fake) - # unknown version warns but must not raise (transient/contract-less server) - _run(vg.assert_backend_compatible("http://backend", min_version="9.9.9")) - - -def test_no_base_url_is_noop(): - _run(vg.assert_backend_compatible(None)) - _run(vg.assert_backend_compatible("")) - - -def test_truthy(monkeypatch): - for val in ("1", "true", "True", "YES", "on"): - monkeypatch.setenv("X_GUARD_FLAG", val) - assert vg._truthy("X_GUARD_FLAG") - for val in ("0", "false", "no", "off", ""): - monkeypatch.setenv("X_GUARD_FLAG", val) - assert not vg._truthy("X_GUARD_FLAG") - monkeypatch.delenv("X_GUARD_FLAG", raising=False) - assert not vg._truthy("X_GUARD_FLAG") # unset โ†’ falsy - - -# --- fetch_backend_version: exercised for real through MockTransport (not mocked out) --- - - -def test_fetch_success_and_url_construction(monkeypatch): - seen = {} - - def handler(request): - seen["url"] = str(request.url) - seen["method"] = request.method - return httpx.Response(200, json={"openapi": "3.1.0", "info": {"version": "0.2.0"}}) - - _patch_transport(monkeypatch, handler) - assert _run(vg.fetch_backend_version("http://backend/")) == "0.2.0" - assert seen["url"] == "http://backend/openapi.json" # trailing slash trimmed, path appended - assert seen["method"] == "GET" - - -def test_fetch_missing_version_field(monkeypatch): - _patch_transport(monkeypatch, lambda r: httpx.Response(200, json={"info": {}})) - assert _run(vg.fetch_backend_version("http://backend")) is None - - -def test_fetch_missing_info_object(monkeypatch): - # `info` absent entirely, and `info: null` โ€” both must coalesce to None, not crash. - _patch_transport(monkeypatch, lambda r: httpx.Response(200, json={})) - assert _run(vg.fetch_backend_version("http://backend")) is None - _patch_transport(monkeypatch, lambda r: httpx.Response(200, json={"info": None})) - assert _run(vg.fetch_backend_version("http://backend")) is None - - -def test_fetch_http_error_status(monkeypatch): - # raise_for_status() โ†’ caught โ†’ None (e.g. server has no /openapi.json) - _patch_transport(monkeypatch, lambda r: httpx.Response(404, text="not found")) - assert _run(vg.fetch_backend_version("http://backend")) is None - _patch_transport(monkeypatch, lambda r: httpx.Response(503, text="unavailable")) - assert _run(vg.fetch_backend_version("http://backend")) is None - - -def test_fetch_non_json_body(monkeypatch): - _patch_transport(monkeypatch, lambda r: httpx.Response(200, text="nope")) - assert _run(vg.fetch_backend_version("http://backend")) is None - - -def test_fetch_connection_error(monkeypatch): - def handler(request): - raise httpx.ConnectError("connection refused", request=request) - - _patch_transport(monkeypatch, handler) - assert _run(vg.fetch_backend_version("http://backend")) is None - - -# --- assert_backend_compatible end-to-end: real fetch through MockTransport, not mocked out --- - - -def test_assert_end_to_end_old_backend_raises(monkeypatch): - monkeypatch.delenv(vg.SKIP_ENV, raising=False) - _patch_transport(monkeypatch, lambda r: httpx.Response(200, json={"info": {"version": "0.0.9"}})) - with pytest.raises(vg.IncompatibleBackendError): - _run(vg.assert_backend_compatible("http://backend", min_version="0.1.0", sdk_version="0.13.0")) - - -def test_assert_end_to_end_new_backend_passes(monkeypatch): - monkeypatch.delenv(vg.SKIP_ENV, raising=False) - _patch_transport(monkeypatch, lambda r: httpx.Response(200, json={"info": {"version": "0.2.0"}})) - _run(vg.assert_backend_compatible("http://backend", min_version="0.1.0")) - - -def test_assert_end_to_end_unreachable_backend_does_not_raise(monkeypatch): - # real fetch returns None on connection failure โ†’ guard proceeds (no crash on transient blip) - monkeypatch.delenv(vg.SKIP_ENV, raising=False) - - def handler(request): - raise httpx.ConnectError("refused", request=request) - - _patch_transport(monkeypatch, handler) - _run(vg.assert_backend_compatible("http://backend", min_version="9.9.9")) diff --git a/uv.lock b/uv.lock index 43de6493c..22d95424b 100644 --- a/uv.lock +++ b/uv.lock @@ -1,27 +1,30 @@ version = 1 revision = 3 -requires-python = ">=3.12, <4" +requires-python = ">=3.9" resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version < '3.13'", -] - -[manifest] -members = [ - "agentex-client", - "agentex-sdk", + "python_full_version >= '3.14' and extra != 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2'", + "python_full_version >= '3.10' and python_full_version < '3.14' and extra != 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2'", + "python_full_version < '3.10' and extra != 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2'", + "python_full_version >= '3.10' and extra == 'group-14-agentex-client-pydantic-v1' and extra != 'group-14-agentex-client-pydantic-v2'", + "python_full_version < '3.10' and extra == 'group-14-agentex-client-pydantic-v1' and extra != 'group-14-agentex-client-pydantic-v2'", + "python_full_version >= '3.10' and extra != 'group-14-agentex-client-pydantic-v1' and extra != 'group-14-agentex-client-pydantic-v2'", + "python_full_version < '3.10' and extra != 'group-14-agentex-client-pydantic-v1' and extra != 'group-14-agentex-client-pydantic-v2'", ] +conflicts = [[ + { package = "agentex-client", group = "pydantic-v1" }, + { package = "agentex-client", group = "pydantic-v2" }, +]] [[package]] name = "agentex-client" -version = "0.17.0" +version = "0.20.0" source = { editable = "." } dependencies = [ { name = "anyio" }, { name = "distro" }, { name = "httpx" }, - { name = "pydantic" }, + { name = "pydantic", version = "1.10.26", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-14-agentex-client-pydantic-v1'" }, + { name = "pydantic", version = "2.12.5", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-14-agentex-client-pydantic-v2' or extra != 'group-14-agentex-client-pydantic-v1'" }, { name = "sniffio" }, { name = "typing-extensions" }, ] @@ -31,28 +34,29 @@ 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-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2')" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' or (extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2')" }, + { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' or (extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2')" }, + { name = "pytest-asyncio", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' or (extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-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-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2')" }, + { name = "time-machine", version = "3.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' or (extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-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] @@ -60,24 +64,19 @@ requires-dist = [ { name = "aiohttp", marker = "extra == 'aiohttp'" }, { name = "anyio", specifier = ">=3.5.0,<5" }, { name = "distro", specifier = ">=1.7.0,<2" }, - { name = "httpx", specifier = ">=0.28.1,<0.29" }, + { name = "httpx", specifier = ">=0.23.0,<1" }, { name = "httpx-aiohttp", marker = "extra == 'aiohttp'", specifier = ">=0.1.9" }, - { name = "pydantic", specifier = ">=2.0.0,<3" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3.4" }, + { name = "pydantic", specifier = ">=1.9.0,<3" }, { name = "sniffio" }, { name = "typing-extensions", specifier = ">=4.14,<5" }, ] -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" }, @@ -86,82 +85,11 @@ dev = [ { name = "rich", specifier = ">=13.7.1" }, { name = "ruff" }, { name = "time-machine" }, - { name = "yaspin", specifier = ">=3.1.0" }, -] - -[[package]] -name = "agentex-sdk" -version = "0.17.0" -source = { editable = "adk" } -dependencies = [ - { name = "agentex-client" }, - { name = "aiohttp" }, - { name = "claude-agent-sdk" }, - { name = "cloudpickle" }, - { name = "ddtrace" }, - { name = "fastapi" }, - { name = "jinja2" }, - { name = "json-log-formatter" }, - { name = "jsonref" }, - { name = "jsonschema" }, - { name = "kubernetes" }, - { name = "langgraph-checkpoint" }, - { name = "litellm" }, - { name = "mcp" }, - { name = "openai" }, - { name = "openai-agents" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-sdk" }, - { name = "pydantic-ai-slim" }, - { name = "python-on-whales" }, - { name = "pyyaml" }, - { name = "questionary" }, - { name = "redis" }, - { name = "rich" }, - { name = "scale-gp" }, - { name = "scale-gp-beta" }, - { name = "starlette" }, - { name = "temporalio" }, - { name = "typer" }, - { name = "uvicorn" }, - { name = "watchfiles" }, - { name = "yaspin" }, ] - -[package.metadata] -requires-dist = [ - { name = "agentex-client", editable = "." }, - { name = "aiohttp", specifier = ">=3.10.10,<4" }, - { name = "claude-agent-sdk", specifier = ">=0.1.0" }, - { name = "cloudpickle", specifier = ">=3.1.1" }, - { name = "ddtrace", specifier = ">=3.13.0" }, - { name = "fastapi", specifier = ">=0.115.0" }, - { name = "jinja2", specifier = ">=3.1.3,<4" }, - { name = "json-log-formatter", specifier = ">=1.1.1" }, - { name = "jsonref", specifier = ">=1.1.0,<2" }, - { name = "jsonschema", specifier = ">=4.23.0,<5" }, - { name = "kubernetes", specifier = ">=25.0.0,<36.0.0" }, - { name = "langgraph-checkpoint", specifier = ">=2.0.0" }, - { name = "litellm", specifier = ">=1.83.7,<2" }, - { name = "mcp", specifier = ">=1.4.1" }, - { name = "openai", specifier = ">=2.2,<2.45" }, - { name = "openai-agents", specifier = ">=0.14.3,<0.15" }, - { name = "opentelemetry-api", specifier = ">=1.20.0" }, - { name = "opentelemetry-sdk", specifier = ">=1.20.0" }, - { name = "pydantic-ai-slim", specifier = ">=1.0,<2" }, - { name = "python-on-whales", specifier = ">=0.73.0,<0.74" }, - { name = "pyyaml", specifier = ">=6.0.2,<7" }, - { name = "questionary", specifier = ">=2.0.1,<3" }, - { name = "redis", specifier = ">=5.2.0,<8" }, - { name = "rich", specifier = ">=13.9.2,<14" }, - { name = "scale-gp", specifier = ">=0.1.0a59" }, - { name = "scale-gp-beta", specifier = ">=0.2.0" }, - { name = "starlette", specifier = ">=0.49.1" }, - { name = "temporalio", specifier = ">=1.26.0,<2" }, - { name = "typer", specifier = ">=0.16,<0.17" }, - { name = "uvicorn", specifier = ">=0.31.1" }, - { name = "watchfiles", specifier = ">=0.24.0,<1.0" }, - { name = "yaspin", specifier = ">=3.1.0" }, +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]] @@ -180,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-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2')" }, { name = "attrs" }, { name = "frozenlist" }, { name = "multidict" }, @@ -188,6 +117,40 @@ 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" }, + { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, + { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, + { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, + { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" }, { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, @@ -256,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]] @@ -264,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-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-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" @@ -294,8 +265,9 @@ name = "anyio" version = "4.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11' or (extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-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-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-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 = [ @@ -303,12 +275,12 @@ wheels = [ ] [[package]] -name = "asttokens" -version = "3.0.1" +name = "async-timeout" +version = "5.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" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, ] [[package]] @@ -321,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]] @@ -338,175 +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/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, -] - -[[package]] -name = "claude-agent-sdk" -version = "0.2.87" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "mcp" }, - { name = "sniffio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/26/dc/e2afd59a1dd6484b6500245fa2331a0d8c0b68e6c180bc29d8ce9540f38a/claude_agent_sdk-0.2.87.tar.gz", hash = "sha256:56f02a49a97f7be37e0cd7323494d1c09e52fb0db7ab94f53bba8a230bb4bd0e", size = 252063, upload-time = "2026-05-23T04:19:25Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/4e/b83c4c6ec1e0b63e9d4d58ba9a5abfd9936c55b8ee4c06b88f5e93bdfd70/claude_agent_sdk-0.2.87-py3-none-macosx_11_0_arm64.whl", hash = "sha256:52204a9609dec3aa96032afd48c07d72e05d13311faf614978f17b61326e6e31", size = 63037960, upload-time = "2026-05-23T04:19:29.056Z" }, - { url = "https://files.pythonhosted.org/packages/13/d7/5fb02260c5b95c66e108c35e046d4d66011921251f7896274b6b21594f14/claude_agent_sdk-0.2.87-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:1713e34e50b830ecac54386d39af14e3a2775f833f1ef715eb53566eaa1b6325", size = 65095745, upload-time = "2026-05-23T04:19:32.533Z" }, - { url = "https://files.pythonhosted.org/packages/1d/84/1061f6580bbbc78de629467abf051cdbbabe71b982297b401e3fde65c7e0/claude_agent_sdk-0.2.87-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:e9e23119d2a02ad1ea1a2707214db98f5baf2c8809577186629843ddfcb8ec18", size = 72725120, upload-time = "2026-05-23T04:19:36.539Z" }, - { url = "https://files.pythonhosted.org/packages/04/50/449f5044d76d9de18cf6a9f4b1c9386a74f41b4e2da5312df245d9dd23ef/claude_agent_sdk-0.2.87-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:5ac525d9ae3481296df5639d005e12ce2b6b0427426991f35da64db30be25c6e", size = 72875504, upload-time = "2026-05-23T04:19:40.839Z" }, - { url = "https://files.pythonhosted.org/packages/80/dd/3f9d7c491d5a98138d293192b31cc9ed792d3552b3a7e276163d7fe2d43a/claude_agent_sdk-0.2.87-py3-none-win_amd64.whl", hash = "sha256:f34973669a1efaeb1543e7b22d7b22feefd8af2fae3adfd39181635077dae432", size = 73514880, upload-time = "2026-05-23T04:19:44.65Z" }, -] - -[[package]] -name = "click" -version = "8.4.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, -] - -[[package]] -name = "cloudpickle" -version = "3.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -516,139 +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" }, -] - -[[package]] -name = "ddtrace" -version = "4.10.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "bytecode" }, - { name = "envier" }, - { name = "opentelemetry-api" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/96/4a/a2c6560cea93dfa9cd42b5d5ab3373f14cda901eb89a1bacf205a294db6e/ddtrace-4.10.1.tar.gz", hash = "sha256:b9951591fafa31296a108e19bda93043c1c73090be114f78f1543a66488ff4ec", size = 2336710, upload-time = "2026-06-01T17:54:13.29Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/e1/d2a1d706fc6ce6d23c8214895b429ece7fba8ea260c6616e76ae87ef628d/ddtrace-4.10.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78cf0f9ced6ffc13d93900ea8a8c1f186ea7031a775b1c4b0769b22b1979cae7", size = 6973760, upload-time = "2026-06-01T17:52:31.341Z" }, - { url = "https://files.pythonhosted.org/packages/75/44/2a11e1aab03cba07896974caad6b0de160bff28ebd666fd8ad6d513a8a2a/ddtrace-4.10.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3f4eca2d73f27eb6bd3bfcb8775aae342fbf39a0e2b697767a5e751328f2e362", size = 7298247, upload-time = "2026-06-01T17:52:33.815Z" }, - { url = "https://files.pythonhosted.org/packages/b7/be/7f978edfaf5c9ee5d80674ef1ffdf85da1bfeeb9d40a85f574094f36b31f/ddtrace-4.10.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:935a032b9333d3a9270bb8a49ea867143bf0426ace874345dbbf38eec2794c9c", size = 8398985, upload-time = "2026-06-01T17:52:35.963Z" }, - { url = "https://files.pythonhosted.org/packages/b4/ab/12782aa4966f7cf26dd7e566c12e714c6692344afbefb9a929b3710bce26/ddtrace-4.10.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6e76874dcd00e78765289338b2e438e46f77864ad55c478ca6c46f0972eff60b", size = 8623502, upload-time = "2026-06-01T17:52:39.212Z" }, - { url = "https://files.pythonhosted.org/packages/ed/7e/848666e0b79be60083ba8f4600d867592c1ecc4312b04e3404e07705f5eb/ddtrace-4.10.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e33e50873d8840b0929598e936ff9e513763d0c02659144740239ca876956532", size = 9406765, upload-time = "2026-06-01T17:52:41.949Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/a4d294da6cc2b0ba151fd5670a47aa7cfe0b7853e966266f8aafb6f689c8/ddtrace-4.10.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d6d76f9604c4bbe92a7737feeea55c09c024035ce72d6c9a300b98da63684c8b", size = 9676840, upload-time = "2026-06-01T17:52:44.417Z" }, - { url = "https://files.pythonhosted.org/packages/93/0d/f2b4b2b2446be8ecf3134f39ab5bb1a4c5889b108f913047fc36e879391f/ddtrace-4.10.1-cp312-cp312-win32.whl", hash = "sha256:d437f2b810de02dd5fd8b69f47c85af88740bcda3718ebb0d6c232621c373aee", size = 5578605, upload-time = "2026-06-01T17:52:47.142Z" }, - { url = "https://files.pythonhosted.org/packages/f5/ae/b2509da7658e6d8c4458227b7f0a3beebe52c174dab6b039b8d7522de851/ddtrace-4.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:6a420e1ed9258a8050c826b4c90343a313fb13dfc6deea1215c689834fba1e82", size = 6150473, upload-time = "2026-06-01T17:52:49.46Z" }, - { url = "https://files.pythonhosted.org/packages/a2/7e/ab135d949d6f48bdb0fe15c64a134a71acc5fb0776fdc22a45dce9f43138/ddtrace-4.10.1-cp312-cp312-win_arm64.whl", hash = "sha256:c68474b8b8352dd76315e19b8bc8f2e895bfe1f4503b4aef56f952360877fb97", size = 5834288, upload-time = "2026-06-01T17:52:51.709Z" }, - { url = "https://files.pythonhosted.org/packages/f7/08/0a2465ec498f1416192a172ba08e035b49b0a6394027581607a86ec6f6c6/ddtrace-4.10.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:266f7bbea4cbaa7d2fd83d2c81319c7e96f9ddc8df7b175d5610a2ae91a308ae", size = 6966669, upload-time = "2026-06-01T17:52:54.249Z" }, - { url = "https://files.pythonhosted.org/packages/37/3a/82f14c16223e2da17c077ba7f37030c4f4ca33b9355294e960f63592209b/ddtrace-4.10.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:1562a89a6612c7d77b6dfb0fa688edf77839b0536927ac37b94185da6d20d922", size = 7291845, upload-time = "2026-06-01T17:52:56.698Z" }, - { url = "https://files.pythonhosted.org/packages/99/08/0f248213ea94f4f3832330340d268368ce7e5b33cd9900234d9044405765/ddtrace-4.10.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b98834cf401356d93a3e8aaa0c5cef62bf9f5eb7ecda062573c3d7be6da9b9ca", size = 8394275, upload-time = "2026-06-01T17:52:59.529Z" }, - { url = "https://files.pythonhosted.org/packages/17/48/bba5f03ac0594a4803f2c4980269d1902b7579ff5d4fc8fd00044dc05d5b/ddtrace-4.10.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:de534b85973021a6e4e62f2be5657b93ad71bb46c1527c604bb8c2ad5e2905b1", size = 8615470, upload-time = "2026-06-01T17:53:02.444Z" }, - { url = "https://files.pythonhosted.org/packages/f7/fc/0d6d3a5640bd18b957396bceae9ec04667af036540592f6787dff54e295e/ddtrace-4.10.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8ca4b419f711e8420ce31940a533d220e48d6c184a3768f3ff328bcacbf378bf", size = 9403713, upload-time = "2026-06-01T17:53:05.364Z" }, - { url = "https://files.pythonhosted.org/packages/3e/fe/893bdef6c0d8c4d5831e01201a41ac0d346d56f18c4f033822b00eb73bc1/ddtrace-4.10.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c5751220f69d90d084fcdb5033552ea0c1b81a8cfe44ced980407cc8a6ad502c", size = 9670193, upload-time = "2026-06-01T17:53:08.425Z" }, - { url = "https://files.pythonhosted.org/packages/b8/5c/a2ab89363394a3f192f169020dd090dac6a5be81e6b84db6b8b6e4e671f8/ddtrace-4.10.1-cp313-cp313-win32.whl", hash = "sha256:de31b005c6f0e83b2bfe60c1a26fa55ec5d9e9ea7608c6c7f06a7579ad414125", size = 5575654, upload-time = "2026-06-01T17:53:11.25Z" }, - { url = "https://files.pythonhosted.org/packages/09/74/12a38c4f5367687aaa72a4e76614826391659c9370fe2199ca0a7076da17/ddtrace-4.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:86eb0df8e2f888f08c2d0e7c9db4a6eb813f9546187ffaab85a5b3b1e9650b38", size = 6147671, upload-time = "2026-06-01T17:53:13.912Z" }, - { url = "https://files.pythonhosted.org/packages/8a/22/f060f07678e2515c9802024169f4ba1a640e1b28aef4b5a5fa7d5d3b76c8/ddtrace-4.10.1-cp313-cp313-win_arm64.whl", hash = "sha256:b9bcb591ac143e70df4b3732e2c0ec056a82b967a9b5a6adbf7f40266d5f9859", size = 5831355, upload-time = "2026-06-01T17:53:16.452Z" }, - { url = "https://files.pythonhosted.org/packages/3c/aa/c8513539633304304b9a3246b2c5c2f4af1ba2d437cfdb7d8c4df5e2bbf9/ddtrace-4.10.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:43d342b0293ed009ea6ede4800a9443b839326c55f61b242a3f6e86162991709", size = 6970858, upload-time = "2026-06-01T17:53:19.116Z" }, - { url = "https://files.pythonhosted.org/packages/fe/02/1b88c5ef89265cd439cc82cc035d93145d0c99913aafad9ecd7430708f3e/ddtrace-4.10.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:8e2e3435c8a647f89dd1f733ea45f677664df6800863eca1f3a75d370aeec3d6", size = 7295182, upload-time = "2026-06-01T17:53:22.044Z" }, - { url = "https://files.pythonhosted.org/packages/67/1a/1f9532c31c137d948c4c1f6bee5b358e16ede086b1a50dd8fbb62c1faa2e/ddtrace-4.10.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e27391bc80a657b3990683b4a555a1fbff266f9bf3eda90c2c23cc6fcc629c2", size = 8402723, upload-time = "2026-06-01T17:53:24.864Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a3/b3d6dd313779da8fd532574254a69a4241d6ddfe3ea6b2ec1b7760661ba0/ddtrace-4.10.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1b79b7c9bc43148ed77637f0a982ec4c7250b75b56ab24343edddd73b905c2c5", size = 8618621, upload-time = "2026-06-01T17:53:27.901Z" }, - { url = "https://files.pythonhosted.org/packages/b8/47/f947235329c79154adc98d2d86398f3eca626b395d59978b5bd649970646/ddtrace-4.10.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3dd12f7efecdd13f94f773868a5ee0baaa5635504bb862d2e9950658d17532a1", size = 9414716, upload-time = "2026-06-01T17:53:31.456Z" }, - { url = "https://files.pythonhosted.org/packages/6e/26/f06cf5039df5b02f514f83af8c0f00f74cb2ff563ecf398fcbd81f4311e7/ddtrace-4.10.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d056716160a8098be4d65386d1a6e99893c21c68b48d283bb0ccd8633ec48d78", size = 9676493, upload-time = "2026-06-01T17:53:34.853Z" }, - { url = "https://files.pythonhosted.org/packages/8e/8a/f16d752c446ad3772c82a21eb5d91efe768f98de460b841461745d75aef0/ddtrace-4.10.1-cp314-cp314-win32.whl", hash = "sha256:6ee375cba898893c9c8c8f1dafcec486d112e7b16871abacf98ede792d0edde1", size = 5674042, upload-time = "2026-06-01T17:53:38.232Z" }, - { url = "https://files.pythonhosted.org/packages/18/d0/20d8184b3be09ce5caf60037684ea72cc75fa824e64ff9bbd6195656d7f2/ddtrace-4.10.1-cp314-cp314-win_amd64.whl", hash = "sha256:7b0a747eae14fc04d25db738f556dc24a12484182ad58b107bece4ff4abb468b", size = 6289030, upload-time = "2026-06-01T17:53:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/9a/4e/2d46162b66097f39dba17f9015a5d64e23ef9beb096394823438dfc3eb9d/ddtrace-4.10.1-cp314-cp314-win_arm64.whl", hash = "sha256:41069ce5232339ad85c5008bf7934bb716c78ba621426c1525b6ebb35c5aff2b", size = 5984250, upload-time = "2026-06-01T17:53:44.17Z" }, -] - -[[package]] -name = "debugpy" -version = "1.8.21" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/df/bf625547431a9cadc9f4cbfeda38866e2b17f6aed147b625377e87834449/debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e", size = 2483609, upload-time = "2026-06-01T19:30:50.794Z" }, - { url = "https://files.pythonhosted.org/packages/bf/09/59324b903599031ff9faaec1758292409f6561a0ec2492fe4b703327705a/debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176", size = 3968900, upload-time = "2026-06-01T19:30:52.341Z" }, - { url = "https://files.pythonhosted.org/packages/14/cd/27f65b805d7fe005c44e1a36b9183ecdfbcdbf9d3e721a5115d461ecc7ee/debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9", size = 5336340, upload-time = "2026-06-01T19:30:54.047Z" }, - { url = "https://files.pythonhosted.org/packages/77/1d/c84e30c0c674184948b66f076ab271c01d940618a2824c23cd035a27bc20/debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c", size = 5374751, upload-time = "2026-06-01T19:30:55.891Z" }, - { url = "https://files.pythonhosted.org/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88", size = 2477398, upload-time = "2026-06-01T19:30:57.644Z" }, - { url = "https://files.pythonhosted.org/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2", size = 3962096, upload-time = "2026-06-01T19:30:59.235Z" }, - { url = "https://files.pythonhosted.org/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1", size = 5336288, upload-time = "2026-06-01T19:31:00.79Z" }, - { url = "https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0", size = 5376567, upload-time = "2026-06-01T19:31:02.56Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" }, - { url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" }, - { url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" }, - { url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" }, - { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, -] - -[[package]] -name = "decorator" -version = "5.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, -] - [[package]] name = "dirty-equals" version = "0.11" @@ -668,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-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-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]] @@ -694,96 +358,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] -[[package]] -name = "executing" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, -] - -[[package]] -name = "fastapi" -version = "0.136.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, -] - -[[package]] -name = "fastjsonschema" -version = "2.21.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, -] - -[[package]] -name = "fastuuid" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26", size = 18232, upload-time = "2025-10-19T22:19:22.402Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a", size = 510164, upload-time = "2025-10-19T22:31:45.635Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d", size = 261837, upload-time = "2025-10-19T22:38:38.53Z" }, - { url = "https://files.pythonhosted.org/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070", size = 251370, upload-time = "2025-10-19T22:40:26.07Z" }, - { url = "https://files.pythonhosted.org/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796", size = 277766, upload-time = "2025-10-19T22:37:23.779Z" }, - { url = "https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09", size = 278105, upload-time = "2025-10-19T22:26:56.821Z" }, - { url = "https://files.pythonhosted.org/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8", size = 301564, upload-time = "2025-10-19T22:30:31.604Z" }, - { url = "https://files.pythonhosted.org/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741", size = 459659, upload-time = "2025-10-19T22:31:32.341Z" }, - { url = "https://files.pythonhosted.org/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057", size = 478430, upload-time = "2025-10-19T22:26:22.962Z" }, - { url = "https://files.pythonhosted.org/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8", size = 450894, upload-time = "2025-10-19T22:27:01.647Z" }, - { url = "https://files.pythonhosted.org/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176", size = 154374, upload-time = "2025-10-19T22:29:19.879Z" }, - { url = "https://files.pythonhosted.org/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397", size = 156550, upload-time = "2025-10-19T22:27:49.658Z" }, - { url = "https://files.pythonhosted.org/packages/a5/83/ae12dd39b9a39b55d7f90abb8971f1a5f3c321fd72d5aa83f90dc67fe9ed/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021", size = 510720, upload-time = "2025-10-19T22:42:34.633Z" }, - { url = "https://files.pythonhosted.org/packages/53/b0/a4b03ff5d00f563cc7546b933c28cb3f2a07344b2aec5834e874f7d44143/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc", size = 262024, upload-time = "2025-10-19T22:30:25.482Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6d/64aee0a0f6a58eeabadd582e55d0d7d70258ffdd01d093b30c53d668303b/fastuuid-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5", size = 251679, upload-time = "2025-10-19T22:36:14.096Z" }, - { url = "https://files.pythonhosted.org/packages/60/f5/a7e9cda8369e4f7919d36552db9b2ae21db7915083bc6336f1b0082c8b2e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f", size = 277862, upload-time = "2025-10-19T22:36:23.302Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d3/8ce11827c783affffd5bd4d6378b28eb6cc6d2ddf41474006b8d62e7448e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87", size = 278278, upload-time = "2025-10-19T22:29:43.809Z" }, - { url = "https://files.pythonhosted.org/packages/a2/51/680fb6352d0bbade04036da46264a8001f74b7484e2fd1f4da9e3db1c666/fastuuid-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b", size = 301788, upload-time = "2025-10-19T22:36:06.825Z" }, - { url = "https://files.pythonhosted.org/packages/fa/7c/2014b5785bd8ebdab04ec857635ebd84d5ee4950186a577db9eff0fb8ff6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022", size = 459819, upload-time = "2025-10-19T22:35:31.623Z" }, - { url = "https://files.pythonhosted.org/packages/01/d2/524d4ceeba9160e7a9bc2ea3e8f4ccf1ad78f3bde34090ca0c51f09a5e91/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995", size = 478546, upload-time = "2025-10-19T22:26:03.023Z" }, - { url = "https://files.pythonhosted.org/packages/bc/17/354d04951ce114bf4afc78e27a18cfbd6ee319ab1829c2d5fb5e94063ac6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab", size = 450921, upload-time = "2025-10-19T22:31:02.151Z" }, - { url = "https://files.pythonhosted.org/packages/fb/be/d7be8670151d16d88f15bb121c5b66cdb5ea6a0c2a362d0dcf30276ade53/fastuuid-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad", size = 154559, upload-time = "2025-10-19T22:36:36.011Z" }, - { url = "https://files.pythonhosted.org/packages/22/1d/5573ef3624ceb7abf4a46073d3554e37191c868abc3aecd5289a72f9810a/fastuuid-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed", size = 156539, upload-time = "2025-10-19T22:33:35.898Z" }, - { url = "https://files.pythonhosted.org/packages/16/c9/8c7660d1fe3862e3f8acabd9be7fc9ad71eb270f1c65cce9a2b7a31329ab/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b852a870a61cfc26c884af205d502881a2e59cc07076b60ab4a951cc0c94d1ad", size = 510600, upload-time = "2025-10-19T22:43:44.17Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f4/a989c82f9a90d0ad995aa957b3e572ebef163c5299823b4027986f133dfb/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c7502d6f54cd08024c3ea9b3514e2d6f190feb2f46e6dbcd3747882264bb5f7b", size = 262069, upload-time = "2025-10-19T22:43:38.38Z" }, - { url = "https://files.pythonhosted.org/packages/da/6c/a1a24f73574ac995482b1326cf7ab41301af0fabaa3e37eeb6b3df00e6e2/fastuuid-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ca61b592120cf314cfd66e662a5b54a578c5a15b26305e1b8b618a6f22df714", size = 251543, upload-time = "2025-10-19T22:32:22.537Z" }, - { url = "https://files.pythonhosted.org/packages/1a/20/2a9b59185ba7a6c7b37808431477c2d739fcbdabbf63e00243e37bd6bf49/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa75b6657ec129d0abded3bec745e6f7ab642e6dba3a5272a68247e85f5f316f", size = 277798, upload-time = "2025-10-19T22:33:53.821Z" }, - { url = "https://files.pythonhosted.org/packages/ef/33/4105ca574f6ded0af6a797d39add041bcfb468a1255fbbe82fcb6f592da2/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8a0dfea3972200f72d4c7df02c8ac70bad1bb4c58d7e0ec1e6f341679073a7f", size = 278283, upload-time = "2025-10-19T22:29:02.812Z" }, - { url = "https://files.pythonhosted.org/packages/fe/8c/fca59f8e21c4deb013f574eae05723737ddb1d2937ce87cb2a5d20992dc3/fastuuid-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1bf539a7a95f35b419f9ad105d5a8a35036df35fdafae48fb2fd2e5f318f0d75", size = 301627, upload-time = "2025-10-19T22:35:54.985Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e2/f78c271b909c034d429218f2798ca4e89eeda7983f4257d7865976ddbb6c/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9a133bf9cc78fdbd1179cb58a59ad0100aa32d8675508150f3658814aeefeaa4", size = 459778, upload-time = "2025-10-19T22:28:00.999Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f0/5ff209d865897667a2ff3e7a572267a9ced8f7313919f6d6043aed8b1caa/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:f54d5b36c56a2d5e1a31e73b950b28a0d83eb0c37b91d10408875a5a29494bad", size = 478605, upload-time = "2025-10-19T22:36:21.764Z" }, - { url = "https://files.pythonhosted.org/packages/e0/c8/2ce1c78f983a2c4987ea865d9516dbdfb141a120fd3abb977ae6f02ba7ca/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ec27778c6ca3393ef662e2762dba8af13f4ec1aaa32d08d77f71f2a70ae9feb8", size = 450837, upload-time = "2025-10-19T22:34:37.178Z" }, - { url = "https://files.pythonhosted.org/packages/df/60/dad662ec9a33b4a5fe44f60699258da64172c39bd041da2994422cdc40fe/fastuuid-0.14.0-cp314-cp314-win32.whl", hash = "sha256:e23fc6a83f112de4be0cc1990e5b127c27663ae43f866353166f87df58e73d06", size = 154532, upload-time = "2025-10-19T22:35:18.217Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f6/da4db31001e854025ffd26bc9ba0740a9cbba2c3259695f7c5834908b336/fastuuid-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a", size = 156457, upload-time = "2025-10-19T22:33:44.579Z" }, -] - -[[package]] -name = "filelock" -version = "3.29.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, -] - [[package]] name = "frozenlist" version = "1.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/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" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, @@ -864,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" @@ -907,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" @@ -952,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" @@ -993,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" @@ -1060,489 +568,118 @@ 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" }, -] -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" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, -] - -[[package]] -name = "ipywidgets" -version = "8.1.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "comm" }, - { name = "ipython" }, - { name = "jupyterlab-widgets" }, - { name = "traitlets" }, - { name = "widgetsnbextension" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4c/ae/c5ce1edc1afe042eadb445e95b0671b03cee61895264357956e61c0d2ac0/ipywidgets-8.1.8.tar.gz", hash = "sha256:61f969306b95f85fba6b6986b7fe45d73124d1d9e3023a8068710d47a22ea668", size = 116739, upload-time = "2025-11-01T21:18:12.393Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl", hash = "sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e", size = 139808, upload-time = "2025-11-01T21:18:10.956Z" }, -] - -[[package]] -name = "jedi" -version = "0.20.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "parso" }, +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-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2'", + "python_full_version >= '3.10' and python_full_version < '3.14' and extra != 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2'", + "python_full_version >= '3.10' and extra == 'group-14-agentex-client-pydantic-v1' and extra != 'group-14-agentex-client-pydantic-v2'", + "python_full_version >= '3.10' and extra != 'group-14-agentex-client-pydantic-v1' and extra != 'group-14-agentex-client-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/44/53/4f6bddbcde3c71e56d0aa1337ec95950f3d27dd4153e25aadf0feac71751/jiter-0.15.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0e90a1c315a0226ec822d973817967f9223b7701546c8c2a7913e7ab0926294d", size = 308793, upload-time = "2026-05-19T10:07:35.25Z" }, - { url = "https://files.pythonhosted.org/packages/01/84/c01099b59a285a1ebba64ae93f62bfa036675340fd1b0045ae65890a0442/jiter-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8c9004af7c8d67cce7f1aae1026fb55607f4aa600710d08ede3a3ce4aeefe7e0", size = 309570, upload-time = "2026-05-19T10:07:36.919Z" }, - { url = "https://files.pythonhosted.org/packages/58/64/8fb7f9d45bb98190355454cd04dad8d8f27223d6bd52f83af07f637168a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c210f8b35dc6f30aafd4b4365ca89b9d1189f21ab49b8e68fa6322a847aef138", size = 336783, upload-time = "2026-05-19T10:07:38.694Z" }, - { url = "https://files.pythonhosted.org/packages/c3/b6/f5739011d009b3a30f6a53c5240979030ba29ae46a8c67e3a15759f7c37d/jiter-0.15.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f30bae8bc1c2d613e28e5af3e8cceb09b742f1c8a8a5f839fb67afaffc03b61", size = 363555, upload-time = "2026-05-19T10:07:40.832Z" }, - { url = "https://files.pythonhosted.org/packages/e5/12/98a9d9f766665e8a3b6252454e17cb0c464606a28cf2fa09399b003345fa/jiter-0.15.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e71b6d10cfc284c9bf36bd885e8d44c46f688ce50aa91b5edd90181dea687", size = 452255, upload-time = "2026-05-19T10:07:42.62Z" }, - { url = "https://files.pythonhosted.org/packages/e8/d5/60f972840f79c5e7544fce567c56f1e4e50468f996baba3e78d823dd62a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ab068bce62a45aa3e7367eceaffb5dde60b7eb853be8dece45132e3d0ff4879", size = 373559, upload-time = "2026-05-19T10:07:44.201Z" }, - { url = "https://files.pythonhosted.org/packages/ee/cf/d46ef1234ba335aabc2f013210db8e0821a22f5e644a2e9449df199ecc23/jiter-0.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa248c9eb220197d363f688818dac2fd4b2f0cd7d843ca7105d652034823427d", size = 346055, upload-time = "2026-05-19T10:07:46.005Z" }, - { url = "https://files.pythonhosted.org/packages/f0/63/4d2749d8d54d230bad9b3a6b0d00cc28c6ff6b2fdffc26a8ccf76cc5a974/jiter-0.15.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2a77aadd57cac1682e4401a72724d2796d89a4ba129b1a5812aa94ee480826eb", size = 351406, upload-time = "2026-05-19T10:07:47.855Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b9/9965b990035d8773328e0a8c8b457a87bf2b19f6c4126d9d99296be5d16a/jiter-0.15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2ae901f3a55bfafdde31d289590fa25e3245735a2b1e8c7cc15871710a002871", size = 389357, upload-time = "2026-05-19T10:07:49.665Z" }, - { url = "https://files.pythonhosted.org/packages/2d/55/9ddf903deda1413e87fed792f416b7123daee5b8efbad6a202a7421c36a5/jiter-0.15.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f0b271b462769543716f92d3a4f90527df6ef5ed05ee95ec4137f513e21e1b77", size = 517263, upload-time = "2026-05-19T10:07:51.537Z" }, - { url = "https://files.pythonhosted.org/packages/e8/76/a0c40ad064d3a20a4fde231e35d56e9a01ce82164278180e82d5daf85469/jiter-0.15.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2fb6a5d26af81fc0f00f9360a891e05cf755e149bba391c4d563adc54812973d", size = 548646, upload-time = "2026-05-19T10:07:53.196Z" }, - { url = "https://files.pythonhosted.org/packages/23/4f/eca9b954942916ba2f453891b8593ab444cd872396fe66a3936616f236f3/jiter-0.15.0-cp312-cp312-win32.whl", hash = "sha256:c2f6bb8b5216ab9e7873bc08b5d7bef2b8abbb578a3069bf1cd14a45d71d771d", size = 206427, upload-time = "2026-05-19T10:07:55.307Z" }, - { url = "https://files.pythonhosted.org/packages/95/bf/8ead82a87495149542748e828d153fd232a512a22c83b02c4815c1a9c7d8/jiter-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:40b2c7e92c44a84d748d21706c68dc6ff8161d80b59c99d774721a0d2317d7c7", size = 197300, upload-time = "2026-05-19T10:07:56.651Z" }, - { url = "https://files.pythonhosted.org/packages/f4/e4/9b8a78fb2d894471bc344e37f1949bdd784bd914d031dba0ba3a40c71dd7/jiter-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:cc0bc345cf2df9d1c00ac443f50d543c1ccfa8b0422cb85b1ab70d681c0b255b", size = 192702, upload-time = "2026-05-19T10:07:58.307Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f4/f708c900ecee41b2025ef8413d5351e5649eb2125c506f6720cc69b06f5c/jiter-0.15.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1c11465f97e2abf45a014b83b730222f8f1c5335e802c7055a67d50de6f1f4e3", size = 307829, upload-time = "2026-05-19T10:07:59.704Z" }, - { url = "https://files.pythonhosted.org/packages/86/59/db537c0949e83668c38481d426b9f2fd5ab758c4ee53a811dd0a510626a0/jiter-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e7b1776f0797956c509e123d0952d10d293a9492dea9f288ab9570ec01d1a5", size = 308445, upload-time = "2026-05-19T10:08:01.184Z" }, - { url = "https://files.pythonhosted.org/packages/37/38/ea0e13b18c30ef951da0d47d39e7fa9edb82a93a62990ffbd7cea9b622d4/jiter-0.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:351a341c2105aa430b7047e30f1bf7975f6313b00165d3fc07be2edaf741f279", size = 336181, upload-time = "2026-05-19T10:08:02.688Z" }, - { url = "https://files.pythonhosted.org/packages/58/fc/2303901b16c4ba05865588990a420c0b4156270b44379c20931544a1d962/jiter-0.15.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ab395feec8d249ec4044e228e98a7033f043426a265df439dc3698823f0a4e4", size = 362985, upload-time = "2026-05-19T10:08:04.394Z" }, - { url = "https://files.pythonhosted.org/packages/5b/6f/11bace093c52e7d4d26c8e606ccd7ae8c972189622469ec0d9e28161e28b/jiter-0.15.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2a438005b6f22d0273413484d6094d7c2c5d10ec1b3a3bf128e0d1d3ba53258", size = 453292, upload-time = "2026-05-19T10:08:05.967Z" }, - { url = "https://files.pythonhosted.org/packages/22/db/987f2f086ca4d7a6582eb4ccd513f9b26b42d9e4243a087609a3137a8fc7/jiter-0.15.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f18f85e4218d1b40f000f42a92239a7a61a902cd42c65e6c360dbd17dcb20894", size = 373501, upload-time = "2026-05-19T10:08:07.857Z" }, - { url = "https://files.pythonhosted.org/packages/8f/7c/89fbcabb2739b7a5b8dc959a1b6c5761f6484f5fed3486854b3c789bb1de/jiter-0.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1aa62e277fc1cbd80e6deacae6f4d983b41b3d7728e0645c5d741a6149bba45", size = 344683, upload-time = "2026-05-19T10:08:09.431Z" }, - { url = "https://files.pythonhosted.org/packages/30/6f/6cca7692e7dddfec6d8d76c54dc97f2af2a41df4ac0674b999df1f09a5f3/jiter-0.15.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:6550fa135c7deb8ead6af49ed7ff648532ea8334a1447fe34a36315ef79c5c29", size = 350892, upload-time = "2026-05-19T10:08:11.352Z" }, - { url = "https://files.pythonhosted.org/packages/39/14/0338d6190cb8e6d22e677ab1d4eabd4117f67cca70c54cd04b82ff64e068/jiter-0.15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:066f8f33f18b2419cd8213b2436fa7fbc9c499f315971cfa3ce1f9820c001b1b", size = 388723, upload-time = "2026-05-19T10:08:12.912Z" }, - { url = "https://files.pythonhosted.org/packages/90/31/cc19f4a1bdb6afb09ce6a2f2615aa8d44d994eba0d8e6105ed1af920e736/jiter-0.15.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:75e8a04e91432dde9f1838373cf93d23726c79d3e908d319acf0e796f85592e7", size = 516648, upload-time = "2026-05-19T10:08:14.808Z" }, - { url = "https://files.pythonhosted.org/packages/49/9f/833c541512cd091b63c10c0381973dfe11bc7a503a818c16384417e0c81e/jiter-0.15.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a97261f1fccb8e50ecd2890a96e46efdc3f57c80a197324c6777827231eca712", size = 547382, upload-time = "2026-05-19T10:08:16.927Z" }, - { url = "https://files.pythonhosted.org/packages/d2/11/e7b70e91f90bc4477e8eee9e8a5f7cf3cb41b4525d6394dc98a714eb8f7f/jiter-0.15.0-cp313-cp313-win32.whl", hash = "sha256:c77496cb10bd7549690fbbab3e5ec05857b83e49276f4a9423a766ddd2afcd4c", size = 205845, upload-time = "2026-05-19T10:08:18.401Z" }, - { url = "https://files.pythonhosted.org/packages/4b/23/5c20d9ad6f02c493e4023e5d2d09e1c1f15fe2753c9102c544aff068a88e/jiter-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b15741f501469009ae0ae90b7147958a664a7dede40aa7ff174a8a4645f546d0", size = 196842, upload-time = "2026-05-19T10:08:20.131Z" }, - { url = "https://files.pythonhosted.org/packages/6b/11/1eb400ef248e8c925fd883fbe325daf5e42cd1b0d308539dd332bd4f7ffc/jiter-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d6a60072b44c3c2b797a7ddcbcbbf2b34ea3cfd4721580fbfd2a09d9d9b84ba", size = 192212, upload-time = "2026-05-19T10:08:21.807Z" }, - { url = "https://files.pythonhosted.org/packages/8a/60/2fd8d7c79da8acf9b7b277c7616847773779356b92acfc9bb158452174da/jiter-0.15.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ef1fd24d9413f6209e00d3d5a453e67acfe004a25cc6c8e8484faed4311ab9e8", size = 315065, upload-time = "2026-05-19T10:08:23.218Z" }, - { url = "https://files.pythonhosted.org/packages/46/f4/008fb7d65e8ac2abf00811651a661e025c4ba80bbc6f378450384ddd3aed/jiter-0.15.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:144f8e72cb53dab146347b91cceac01f5481237f2b93b4a339a1ee8f8878b67c", size = 339444, upload-time = "2026-05-19T10:08:24.701Z" }, - { url = "https://files.pythonhosted.org/packages/00/55/90b0c7b9c6896c0f2a591dd36d36b71d22e09674bfef178fa03ba3f81499/jiter-0.15.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553fcac2ef2cb990877f9fc0833b8b629a3e6a5670b6b5fd58219b41a653ddc4", size = 347779, upload-time = "2026-05-19T10:08:26.408Z" }, - { url = "https://files.pythonhosted.org/packages/51/6b/69666cec5000fd57734c118437394516c749ae8dbeea9fb66d6fef9c4775/jiter-0.15.0-cp313-cp313t-win_amd64.whl", hash = "sha256:774f93f65031856bf14ad9f59bdcab8b8cad501e5ceabd51ba3525f76937a25b", size = 200395, upload-time = "2026-05-19T10:08:28.055Z" }, - { url = "https://files.pythonhosted.org/packages/39/04/a6aa62cd27e8149b0d28df5561f10f6cceaf7935a9ccf3f1c5a05f9a0cd8/jiter-0.15.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f1e1754960f38ec40613a07e5e372df67acb3b890fb383b6fb3de3e49ddbf3c7", size = 190516, upload-time = "2026-05-19T10:08:29.35Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d2/079f350ebf7859d081de30aa890f9e3be68516f754f3ba32366ffff4dcee/jiter-0.15.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ac0d9ddea4350974be7a221fc25895f251a8fee748c889bdced2141c0fec1a49", size = 308884, upload-time = "2026-05-19T10:08:31.667Z" }, - { url = "https://files.pythonhosted.org/packages/04/4e/a2c30a7f69b48c03b20935d647479106fe932f6e63f75faf53937197e05d/jiter-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01a8222cf05ab1128e239421156c207949808acaaea2bdfd33130ae666786e86", size = 310028, upload-time = "2026-05-19T10:08:33.304Z" }, - { url = "https://files.pythonhosted.org/packages/40/90/2e7cdfd3cf8ca967be38c48f5cf474d79f089efaf559a40f15984a77ae69/jiter-0.15.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:182226cbc930c9fab81bc2e41a4da672f89539906dadb05e75670ac07b94f71f", size = 337485, upload-time = "2026-05-19T10:08:35.259Z" }, - { url = "https://files.pythonhosted.org/packages/9b/11/15a1aa28b120b8ee5b4f1fb894c125046225f09847738bd64233d3b84883/jiter-0.15.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:71683c38c825452999b5717fcae07ea708e8c93003e808be4319c1b02e3d176e", size = 364223, upload-time = "2026-05-19T10:08:36.694Z" }, - { url = "https://files.pythonhosted.org/packages/b7/25/f442e8af5f3d0dcf47b39e83a0efd9ee45ea946aa6d04625dc3181eae3b6/jiter-0.15.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30f2218e6a9e5c18bc10fe6d41ac189c442c88eacf11bad9f28ef95a9bef00e6", size = 456387, upload-time = "2026-05-19T10:08:38.143Z" }, - { url = "https://files.pythonhosted.org/packages/da/f4/37f2d2c9f64f49af7da652ed7532bb5a2372e588e6927c3fdd76f911db65/jiter-0.15.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5157de9f76eb4bc5ea74a1219366a25f945ad305641d74e04f59c54087091aa9", size = 374461, upload-time = "2026-05-19T10:08:39.869Z" }, - { url = "https://files.pythonhosted.org/packages/60/28/edcfbbbf0cb15436f36664a8908a0df47ab9006298d4cd937dc08ea932d6/jiter-0.15.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c5db5527c221249a876160663ab891ace358c17f7b9c93ec1478b7f0550e5c", size = 345924, upload-time = "2026-05-19T10:08:41.668Z" }, - { url = "https://files.pythonhosted.org/packages/47/13/89fba6398dab7f202b7278c4b4aac122399d2c0183971c4a57a3b7088df5/jiter-0.15.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:3e4540b8e74e4268811ac05db226a6a128ff572e7e0ce3f1163b693cadb184cd", size = 352283, upload-time = "2026-05-19T10:08:43.091Z" }, - { url = "https://files.pythonhosted.org/packages/1b/da/0f6af8cef2c565a1ab44d970f268c43ccaa72707386ea6388e6fe2b6cd26/jiter-0.15.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:62ebd14e47e9aed9df4472afcb2663668ce4d74891cd54f86bf6e44029d6dc89", size = 389985, upload-time = "2026-05-19T10:08:44.915Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ec/b9cb7d6d29e24ee14910266157d2a279d7a8f60ee0df7fa840882976ba64/jiter-0.15.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0be6f5ad41a809f303f416d17cec92a7a725902fb9b4f3de3d19362ac0ef8554", size = 517695, upload-time = "2026-05-19T10:08:46.486Z" }, - { url = "https://files.pythonhosted.org/packages/64/5e/6d1bda880723aae0ad86b4b763f044362448efe31e3e819635d41cb03451/jiter-0.15.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:813dfbb17d65328bf86e5f0905dd277ba2265d3ca20556e86c0c7035b7182e5a", size = 548868, upload-time = "2026-05-19T10:08:48.026Z" }, - { url = "https://files.pythonhosted.org/packages/0c/72/7de501cf38dcacaf35098796f3a50e0f2e338baba18a58946c618544b809/jiter-0.15.0-cp314-cp314-win32.whl", hash = "sha256:50e51156192722a9c58db112837d3f8ef96fb3c5ecc14e95f409134b08b158ec", size = 206380, upload-time = "2026-05-19T10:08:49.738Z" }, - { url = "https://files.pythonhosted.org/packages/1e/a9/e19addf4b0c1bdce52c6da12351e6bc42c340c45e7c09e2158e46d293ccc/jiter-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:30ce1a5d16b5641dc935d50ef775af6a0871e3d14ab05d6fc54dff371b78e558", size = 197687, upload-time = "2026-05-19T10:08:51.088Z" }, - { url = "https://files.pythonhosted.org/packages/f2/c9/776b1db01db25fc6c1d58d1979a37b0a9fe787e5f5b1d062d2eaacb77923/jiter-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:510c8b3c17a0ed9ac69850c0438dada3c9b82d9c4d589fcb62002a5a9cf3a866", size = 192571, upload-time = "2026-05-19T10:08:52.451Z" }, - { url = "https://files.pythonhosted.org/packages/a0/f6/45bb4670bacf300fd2c7abadbfb3af376e5f1b6ae75fd9bc069891d15870/jiter-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7553333dd0930c104a5a0db8df72bf7219fe663d731383b576bb6ed6351c984d", size = 317151, upload-time = "2026-05-19T10:08:53.867Z" }, - { url = "https://files.pythonhosted.org/packages/d7/68/ed635ad5acd7b73e454283083bbb7c8205ad10e88b0d9d7d793b09fe8226/jiter-0.15.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2143ab06181d2b029eedcb6af3cebe95f11bbac62441781860f98ee9330a6a6", size = 341243, upload-time = "2026-05-19T10:08:55.383Z" }, - { url = "https://files.pythonhosted.org/packages/5d/db/3ff4176b817b8ea33879e71e13d8bc2b0d481a7ed3fe9e080f333d415c16/jiter-0.15.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eac374c5c975709b69c10f09afd199df74150172156ad10c8d4fd785b7da995", size = 363629, upload-time = "2026-05-19T10:08:56.928Z" }, - { url = "https://files.pythonhosted.org/packages/ab/24/5f8270e0ba9c883582f96f722f8a0b58015c7ce1f8c6d4571cf394e99b6b/jiter-0.15.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b3b3b775e33d3bfaec9899edc526ae97b0da0bf9d071a46124ba419149a414f8", size = 456198, upload-time = "2026-05-19T10:08:58.618Z" }, - { url = "https://files.pythonhosted.org/packages/45/5b/76fc02b0b5c54c3d18c60653156e2f76fde1816f9b4722db68d6ee2c897e/jiter-0.15.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3071db3346334beae1360b46da4606da57bf3528c167b3c38533afaf9f2c5", size = 373710, upload-time = "2026-05-19T10:09:00.151Z" }, - { url = "https://files.pythonhosted.org/packages/c4/52/4310821b0ea9277994d3e1f49fc6a4b34e4800caebacb2c0af81da59a454/jiter-0.15.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6694a173ecabc12eb60efbc0b474464ead1951ff65cd8b1e72100715c64512b", size = 349901, upload-time = "2026-05-19T10:09:01.621Z" }, - { url = "https://files.pythonhosted.org/packages/93/fe/67648c35b3594fba8854ac64cc8a826d8bcd18324bbdb53d77697c60b6ef/jiter-0.15.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a254e10b593624d230c365b6d616b22ca0ad65e63a16e6631c2b3466022e6ba8", size = 352438, upload-time = "2026-05-19T10:09:03.216Z" }, - { url = "https://files.pythonhosted.org/packages/cb/28/0a1879d07ad6b3e025a2750027363452ced93c2d16d1c9d4b153ffd51c91/jiter-0.15.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d8d2955167274e15d79a7a020afdd9b39c990eb80b2d89fca695d92dcfdd38ec", size = 388152, upload-time = "2026-05-19T10:09:04.741Z" }, - { url = "https://files.pythonhosted.org/packages/c1/78/46c6f6b56ba85c90021f4afd72ed42f691f8f84daacb5fe27277070e3858/jiter-0.15.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:acf4ee4d1fc55917239fe72972fb292dd773055d05eb040d36f4326e02cc2c0e", size = 517707, upload-time = "2026-05-19T10:09:06.231Z" }, - { url = "https://files.pythonhosted.org/packages/ca/cb/720662d4c88fcad606e826fef5424365527ba43ce4868a479aed8f8c507e/jiter-0.15.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e7196e56f1cd69af1dbb07dff02dcfb260a50b45a82d409d92a06fedb32473b5", size = 548241, upload-time = "2026-05-19T10:09:08.093Z" }, - { url = "https://files.pythonhosted.org/packages/60/e3/935b8034fd143f21125c87d51404a9e0e1449186a494405721ff5d1d695e/jiter-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:7f6163c0f10b055245f814dcc59f4818da60dfe72f3e72ab89fc24b6bd5e9c52", size = 207950, upload-time = "2026-05-19T10:09:09.616Z" }, - { url = "https://files.pythonhosted.org/packages/93/59/984fd9ece895953dad3e0880a650e766f5a2da2c5514f0eafdaaabbeb5f9/jiter-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c256edb05b78a111b99c4de3b1d32e31634b867fd1fc2cf726e7b7bba9854", size = 200055, upload-time = "2026-05-19T10:09:11.367Z" }, - { url = "https://files.pythonhosted.org/packages/0e/a4/cf8d779feb133a27a2e3bc833bccb9e13aa332cdf820497ebf72c10ce8c3/jiter-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:66b1880df2d01e206e8339769d1c7c1753bcb653efd6289e203f6f24ebada0c0", size = 191244, upload-time = "2026-05-19T10:09:12.74Z" }, - { url = "https://files.pythonhosted.org/packages/73/38/505941b2b092fd5bbbd60a52a880db1173f1690ae6751bed3af1c9ddcb4e/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:631f13a3d04e97d4e083993b10f4b99530e3a10d953e2eb5e196b7dc7f812ce0", size = 303769, upload-time = "2026-05-19T10:09:42.203Z" }, - { url = "https://files.pythonhosted.org/packages/e7/95/a06692b29e77473f286e1ec1f426d3ca44d7b5843be8ad21d7a5f3fcdcc0/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b6c0ffae686c39bf3737be60793783267628783ea42545632c10b291105aee45", size = 305128, upload-time = "2026-05-19T10:09:43.657Z" }, - { url = "https://files.pythonhosted.org/packages/23/85/7270d7ad41d6061a25b950c6bf91d638bd9aacb113200a8c8d57a055fd67/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d54fb5b31dea401a41af3f8a7d2512e9b6a6a005491e6166c7e4ffab9639a9c", size = 340459, upload-time = "2026-05-19T10:09:45.452Z" }, - { url = "https://files.pythonhosted.org/packages/c8/8d/302cb2057b7513327b4d575cff6b1d066ee6431a5357fc3f8867cd684406/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a", size = 344469, upload-time = "2026-05-19T10:09:46.864Z" }, +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" }, -] -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" }, + { name = "mdurl", marker = "python_full_version < '3.10' or (extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2')" }, ] - -[[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-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2'", + "python_full_version >= '3.10' and python_full_version < '3.14' and extra != 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2'", + "python_full_version >= '3.10' and extra == 'group-14-agentex-client-pydantic-v1' and extra != 'group-14-agentex-client-pydantic-v2'", + "python_full_version >= '3.10' and extra != 'group-14-agentex-client-pydantic-v1' and extra != 'group-14-agentex-client-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-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-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" }, -] -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" }, + { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2')" }, ] - -[[package]] -name = "jupyterlab-widgets" -version = "3.0.16" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/2d/ef58fed122b268c69c0aa099da20bc67657cdfb2e222688d5731bd5b971d/jupyterlab_widgets-3.0.16.tar.gz", hash = "sha256:423da05071d55cf27a9e602216d35a3a65a3e41cdf9c5d3b643b814ce38c19e0", size = 897423, upload-time = "2025-11-01T21:11:29.724Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8", size = 914926, upload-time = "2025-11-01T21:11:28.008Z" }, -] - -[[package]] -name = "kubernetes" -version = "35.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "durationpy" }, - { name = "python-dateutil" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "requests-oauthlib" }, - { name = "six" }, - { name = "urllib3" }, - { name = "websocket-client" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2c/8f/85bf51ad4150f64e8c665daf0d9dfe9787ae92005efb9a4d1cba592bd79d/kubernetes-35.0.0.tar.gz", hash = "sha256:3d00d344944239821458b9efd484d6df9f011da367ecb155dadf9513f05f09ee", size = 1094642, upload-time = "2026-01-16T01:05:27.76Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/70/05b685ea2dffcb2adbf3cdcea5d8865b7bc66f67249084cf845012a0ff13/kubernetes-35.0.0-py2.py3-none-any.whl", hash = "sha256:39e2b33b46e5834ef6c3985ebfe2047ab39135d41de51ce7641a7ca5b372a13d", size = 2017602, upload-time = "2026-01-16T01:05:25.991Z" }, -] - -[[package]] -name = "langchain-core" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jsonpatch" }, - { name = "langchain-protocol" }, - { name = "langsmith" }, - { name = "packaging" }, - { name = "pydantic" }, - { name = "pyyaml" }, - { name = "tenacity" }, - { name = "typing-extensions" }, - { name = "uuid-utils" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" }, -] - -[[package]] -name = "langchain-protocol" -version = "0.0.16" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/36/e7/8300ba22d968653051fd06e3117d783872dddf3dcebdd6b1d386836eb43c/langchain_protocol-0.0.16.tar.gz", hash = "sha256:806c7cdd951b1c4f692fa40fce60821ff0f221d4360e27673ddf2c2b99c2b7ff", size = 5969, upload-time = "2026-05-28T23:05:11.121Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/9c/06dfcc88d02a6364e8d864c421ddd3736305cb0a6c853f75c302c80fe17c/langchain_protocol-0.0.16-py3-none-any.whl", hash = "sha256:3658c142c5d0fb3a023a4be442ce4c15c6d626aab6135eb79a76dc64ad19c3c3", size = 7037, upload-time = "2026-05-28T23:05:10.163Z" }, -] - -[[package]] -name = "langgraph-checkpoint" -version = "4.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "langchain-core" }, - { name = "ormsgpack" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/83/47/886af6f886f0bff2273164a45f008694e48a96ff3cd25ff0228f2aa9480e/langgraph_checkpoint-4.1.1.tar.gz", hash = "sha256:6c2bdb530c91f91d7d9c1bd100925d0fc4f498d418c17f3587d1526279482a25", size = 184020, upload-time = "2026-05-22T16:57:38.503Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl", hash = "sha256:25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e", size = 56212, upload-time = "2026-05-22T16:57:37.203Z" }, -] - -[[package]] -name = "langsmith" -version = "0.8.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "httpx" }, - { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, - { name = "packaging" }, - { name = "pydantic" }, - { name = "requests" }, - { name = "requests-toolbelt" }, - { name = "uuid-utils" }, - { name = "websockets" }, - { name = "xxhash" }, - { name = "zstandard" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2f/93/28df12b3b3c776077983b92f1299c623592b5999695af2a755fb90ff048b/langsmith-0.8.8.tar.gz", hash = "sha256:9d00e54f54d833c1914003527ff03ad0364741034330da72f0adbeaba852b6cf", size = 4468035, upload-time = "2026-05-31T22:14:57.698Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/71/94a8f2b573278a0b0b7dfd37663c0ddd36867f9e2bba69addd183de0cd56/langsmith-0.8.8-py3-none-any.whl", hash = "sha256:9d60d724c0d187c036e184b3ffdf9fa5c6822aa0bb88144a5fb898e79be645af", size = 402712, upload-time = "2026-05-31T22:14:55.908Z" }, -] - -[[package]] -name = "litellm" -version = "1.87.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "click" }, - { name = "fastuuid" }, - { name = "httpx" }, - { name = "importlib-metadata" }, - { name = "jinja2" }, - { name = "jsonschema" }, - { name = "openai" }, - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "tiktoken" }, - { name = "tokenizers" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/77/0d/ccdf682ccfd7f18bf0e179c39d85616b8f8ef05a798588285310412db13d/litellm-1.87.0.tar.gz", hash = "sha256:cafc1882cb0cbab8374c41180af86e4a067796e4524e15f59e99f6e689cd1bd8", size = 15453755, upload-time = "2026-06-02T03:53:29.076Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/20/88a372fa7e50fc2c33458c6eef94a79afcf7bdfa43610079531b82b484a3/litellm-1.87.0-py3-none-any.whl", hash = "sha256:fbbba7e47ae29b55f878fe1acc80effb92761bc168f6236bd81a0cb6e147d855", size = 17103948, upload-time = "2026-06-02T03:53:25.677Z" }, -] - -[[package]] -name = "logfire-api" -version = "4.35.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d1/25/016b7d5e0433ae28d8a8bcb18681c48da2a0cdbf0ca8f7b2acdac2f16f4a/logfire_api-4.35.0.tar.gz", hash = "sha256:dcc073c7e337b0005f63075cf89951bacf00944b7c7420c2422b18133c8d2605", size = 83091, upload-time = "2026-06-02T14:55:58.573Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/3a/861f2040b251aa12b653b104c314b7d140cb44a6ab19cb141535aa72beb9/logfire_api-4.35.0-py3-none-any.whl", hash = "sha256:c8eb8f49c261c09b3d815b22ecba1c5224e8ba9aa9b546b0afcdb13a89fa6bfe", size = 131026, upload-time = "2026-06-02T14:55:55.252Z" }, -] - -[[package]] -name = "markdown-it-py" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, -] - -[[package]] -name = "matplotlib-inline" -version = "0.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, -] - -[[package]] -name = "mcp" -version = "1.27.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, - { name = "jsonschema" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "pyjwt", extra = ["crypto"] }, - { name = "python-multipart" }, - { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "sse-starlette" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, - { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/27/3c/347cf965d313f5d41764e7d46bea6ffe7d9ef13b983cc429b0340962a082/mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef", size = 621116, upload-time = "2026-05-29T17:16:04.039Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/11/252c6f971dc4f16af1d98a1c469d8ba523aab00d1bb76b4d3bc1ff32eacc/mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5", size = 220498, upload-time = "2026-05-29T17:16:02.442Z" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, -] - -[[package]] -name = "multidict" -version = "6.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } +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" }, + { url = "https://files.pythonhosted.org/packages/31/61/0c2d50241ada71ff61a79518db85ada85fdabfcf395d5968dae1cbda04e5/multidict-6.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a265acbb7bb33a3a2d626afbe756371dce0279e7b17f4f4eda406459c2b5ff1c", size = 245212, upload-time = "2025-10-06T14:48:58.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e0/919666a4e4b57fff1b57f279be1c9316e6cdc5de8a8b525d76f6598fefc7/multidict-6.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51cb455de290ae462593e5b1cb1118c5c22ea7f0d3620d9940bf695cea5a4bd7", size = 246671, upload-time = "2025-10-06T14:49:00.004Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cc/d027d9c5a520f3321b65adea289b965e7bcbd2c34402663f482648c716ce/multidict-6.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db99677b4457c7a5c5a949353e125ba72d62b35f74e26da141530fbb012218a7", size = 225491, upload-time = "2025-10-06T14:49:01.393Z" }, + { url = "https://files.pythonhosted.org/packages/75/c4/bbd633980ce6155a28ff04e6a6492dd3335858394d7bb752d8b108708558/multidict-6.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f470f68adc395e0183b92a2f4689264d1ea4b40504a24d9882c27375e6662bb9", size = 257322, upload-time = "2025-10-06T14:49:02.745Z" }, + { url = "https://files.pythonhosted.org/packages/4c/6d/d622322d344f1f053eae47e033b0b3f965af01212de21b10bcf91be991fb/multidict-6.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0db4956f82723cc1c270de9c6e799b4c341d327762ec78ef82bb962f79cc07d8", size = 254694, upload-time = "2025-10-06T14:49:04.15Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9f/78f8761c2705d4c6d7516faed63c0ebdac569f6db1bef95e0d5218fdc146/multidict-6.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e56d780c238f9e1ae66a22d2adf8d16f485381878250db8d496623cd38b22bd", size = 246715, upload-time = "2025-10-06T14:49:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/950818e04f91b9c2b95aab3d923d9eabd01689d0dcd889563988e9ea0fd8/multidict-6.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d14baca2ee12c1a64740d4531356ba50b82543017f3ad6de0deb943c5979abb", size = 243189, upload-time = "2025-10-06T14:49:07.37Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3d/77c79e1934cad2ee74991840f8a0110966d9599b3af95964c0cd79bb905b/multidict-6.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:295a92a76188917c7f99cda95858c822f9e4aae5824246bba9b6b44004ddd0a6", size = 237845, upload-time = "2025-10-06T14:49:08.759Z" }, + { url = "https://files.pythonhosted.org/packages/63/1b/834ce32a0a97a3b70f86437f685f880136677ac00d8bce0027e9fd9c2db7/multidict-6.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39f1719f57adbb767ef592a50ae5ebb794220d1188f9ca93de471336401c34d2", size = 246374, upload-time = "2025-10-06T14:49:10.574Z" }, + { url = "https://files.pythonhosted.org/packages/23/ef/43d1c3ba205b5dec93dc97f3fba179dfa47910fc73aaaea4f7ceb41cec2a/multidict-6.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0a13fb8e748dfc94749f622de065dd5c1def7e0d2216dba72b1d8069a389c6ff", size = 253345, upload-time = "2025-10-06T14:49:12.331Z" }, + { url = "https://files.pythonhosted.org/packages/6b/03/eaf95bcc2d19ead522001f6a650ef32811aa9e3624ff0ad37c445c7a588c/multidict-6.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e3aa16de190d29a0ea1b48253c57d99a68492c8dd8948638073ab9e74dc9410b", size = 246940, upload-time = "2025-10-06T14:49:13.821Z" }, + { url = "https://files.pythonhosted.org/packages/e8/df/ec8a5fd66ea6cd6f525b1fcbb23511b033c3e9bc42b81384834ffa484a62/multidict-6.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a048ce45dcdaaf1defb76b2e684f997fb5abf74437b6cb7b22ddad934a964e34", size = 242229, upload-time = "2025-10-06T14:49:15.603Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a2/59b405d59fd39ec86d1142630e9049243015a5f5291ba49cadf3c090c541/multidict-6.7.0-cp311-cp311-win32.whl", hash = "sha256:a90af66facec4cebe4181b9e62a68be65e45ac9b52b67de9eec118701856e7ff", size = 41308, upload-time = "2025-10-06T14:49:16.871Z" }, + { url = "https://files.pythonhosted.org/packages/32/0f/13228f26f8b882c34da36efa776c3b7348455ec383bab4a66390e42963ae/multidict-6.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:95b5ffa4349df2887518bb839409bcf22caa72d82beec453216802f475b23c81", size = 46037, upload-time = "2025-10-06T14:49:18.457Z" }, + { url = "https://files.pythonhosted.org/packages/84/1f/68588e31b000535a3207fd3c909ebeec4fb36b52c442107499c18a896a2a/multidict-6.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:329aa225b085b6f004a4955271a7ba9f1087e39dcb7e65f6284a988264a63912", size = 43023, upload-time = "2025-10-06T14:49:19.648Z" }, { url = "https://files.pythonhosted.org/packages/c2/9e/9f61ac18d9c8b475889f32ccfa91c9f59363480613fc807b6e3023d6f60b/multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184", size = 76877, upload-time = "2025-10-06T14:49:20.884Z" }, { url = "https://files.pythonhosted.org/packages/38/6f/614f09a04e6184f8824268fce4bc925e9849edfa654ddd59f0b64508c595/multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45", size = 45467, upload-time = "2025-10-06T14:49:22.054Z" }, { url = "https://files.pythonhosted.org/packages/b3/93/c4f67a436dd026f2e780c433277fff72be79152894d9fc36f44569cab1a6/multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa", size = 43834, upload-time = "2025-10-06T14:49:23.566Z" }, @@ -1633,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" }, ] @@ -1643,10 +798,23 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-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" }, + { url = "https://files.pythonhosted.org/packages/82/16/b775047054de4d8dbd668df9137707e54b07fe18c7923839cd1e524bf756/mypy-1.17.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24cfcc1179c4447854e9e406d3af0f77736d631ec87d31c6281ecd5025df625d", size = 12571106, upload-time = "2025-07-14T20:34:26.942Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cf/fa33eaf29a606102c8d9ffa45a386a04c2203d9ad18bf4eef3e20c43ebc8/mypy-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c56f180ff6430e6373db7a1d569317675b0a451caf5fef6ce4ab365f5f2f6c3", size = 12759960, upload-time = "2025-07-14T20:33:42.882Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/3f5a29209f27e739ca57e6350bc6b783a38c7621bdf9cac3ab8a08665801/mypy-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:eafaf8b9252734400f9b77df98b4eee3d2eecab16104680d51341c75702cad70", size = 9503888, upload-time = "2025-07-14T20:32:34.392Z" }, { url = "https://files.pythonhosted.org/packages/12/e9/e6824ed620bbf51d3bf4d6cbbe4953e83eaf31a448d1b3cfb3620ccb641c/mypy-1.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f986f1cab8dbec39ba6e0eaa42d4d3ac6686516a5d3dccd64be095db05ebc6bb", size = 11086395, upload-time = "2025-07-14T20:34:11.452Z" }, { url = "https://files.pythonhosted.org/packages/ba/51/a4afd1ae279707953be175d303f04a5a7bd7e28dc62463ad29c1c857927e/mypy-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:51e455a54d199dd6e931cd7ea987d061c2afbaf0960f7f66deef47c90d1b304d", size = 10120052, upload-time = "2025-07-14T20:33:09.897Z" }, { url = "https://files.pythonhosted.org/packages/8a/71/19adfeac926ba8205f1d1466d0d360d07b46486bf64360c54cb5a2bd86a8/mypy-1.17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3204d773bab5ff4ebbd1f8efa11b498027cd57017c003ae970f310e5b96be8d8", size = 11861806, upload-time = "2025-07-14T20:32:16.028Z" }, @@ -1659,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" }, ] @@ -1671,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" @@ -1728,184 +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/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, - { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, - { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, - { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, - { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, - { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, - { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, - { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, - { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, - { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, - { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, - { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, - { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, - { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, - { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, - { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, - { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, - { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, - { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, - { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, - { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, - { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, - { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, - { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, - { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, - { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, - { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, - { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, - { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, - { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, - { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, - { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, - { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, - { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, - { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, - { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, - { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, - { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, - { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, -] - -[[package]] -name = "ormsgpack" -version = "1.12.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" }, - { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" }, - { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" }, - { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" }, - { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" }, - { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" }, - { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" }, - { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" }, - { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" }, - { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" }, - { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" }, - { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" }, - { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" }, - { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" }, - { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" }, - { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" }, - { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" }, - { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" }, - { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" }, - { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" }, - { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" }, - { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" }, - { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" }, - { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" }, - { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" }, - { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" }, - { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" }, - { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" }, - { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" }, - { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" }, -] - [[package]] name = "packaging" version = "25.0" @@ -1915,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" @@ -1933,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" @@ -1963,24 +881,42 @@ 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" }, + { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, + { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, + { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, + { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, + { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, @@ -2056,121 +992,128 @@ 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-14-agentex-client-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-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2'", + "python_full_version >= '3.10' and python_full_version < '3.14' and extra != 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2'", + "python_full_version < '3.10' and extra != 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2'", + "python_full_version >= '3.10' and extra != 'group-14-agentex-client-pydantic-v1' and extra != 'group-14-agentex-client-pydantic-v2'", + "python_full_version < '3.10' and extra != 'group-14-agentex-client-pydantic-v1' and extra != 'group-14-agentex-client-pydantic-v2'", +] dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, + { name = "annotated-types", marker = "extra == 'group-14-agentex-client-pydantic-v2' or extra != 'group-14-agentex-client-pydantic-v1'" }, + { name = "pydantic-core", marker = "extra == 'group-14-agentex-client-pydantic-v2' or extra != 'group-14-agentex-client-pydantic-v1'" }, + { name = "typing-extensions", marker = "extra == 'group-14-agentex-client-pydantic-v2' or extra != 'group-14-agentex-client-pydantic-v1'" }, + { name = "typing-inspection", marker = "extra == 'group-14-agentex-client-pydantic-v2' or extra != 'group-14-agentex-client-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-14-agentex-client-pydantic-v2' or extra != 'group-14-agentex-client-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" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, @@ -2227,100 +1170,143 @@ 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" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] [[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-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2') or (sys_platform != 'win32' and extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2')" }, + { name = "exceptiongroup", marker = "python_full_version < '3.10' or (extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2')" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' or (extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2')" }, + { name = "packaging", marker = "python_full_version < '3.10' or (extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2')" }, + { name = "pluggy", marker = "python_full_version < '3.10' or (extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2')" }, + { name = "pygments", marker = "python_full_version < '3.10' or (extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2')" }, + { name = "tomli", marker = "python_full_version < '3.10' or (extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-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-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2'", + "python_full_version >= '3.10' and python_full_version < '3.14' and extra != 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2'", + "python_full_version >= '3.10' and extra == 'group-14-agentex-client-pydantic-v1' and extra != 'group-14-agentex-client-pydantic-v2'", + "python_full_version >= '3.10' and extra != 'group-14-agentex-client-pydantic-v1' and extra != 'group-14-agentex-client-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-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2') or (sys_platform != 'win32' and extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2')" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*' or (extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2')" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' or (extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2')" }, + { name = "packaging", marker = "python_full_version >= '3.10' or (extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2')" }, + { name = "pluggy", marker = "python_full_version >= '3.10' or (extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2')" }, + { name = "pygments", marker = "python_full_version >= '3.10' or (extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2')" }, + { name = "tomli", marker = "python_full_version == '3.10.*' or (extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-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-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2')" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' or (extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2')" }, + { name = "typing-extensions", marker = "python_full_version < '3.10' or (extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-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-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2'", + "python_full_version >= '3.10' and python_full_version < '3.14' and extra != 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2'", + "python_full_version >= '3.10' and extra == 'group-14-agentex-client-pydantic-v1' and extra != 'group-14-agentex-client-pydantic-v2'", + "python_full_version >= '3.10' and extra != 'group-14-agentex-client-pydantic-v1' and extra != 'group-14-agentex-client-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-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2')" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' or (extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-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-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2') or (python_full_version >= '3.13' and extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-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 = [ @@ -2333,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-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2')" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' or (extra == 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-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 = [ @@ -2345,272 +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-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-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/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, - { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, - { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, - { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, - { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, - { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, - { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, - { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, -] - -[[package]] -name = "questionary" -version = "2.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "prompt-toolkit" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, -] - -[[package]] -name = "redis" -version = "7.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, -] - -[[package]] -name = "referencing" -version = "0.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "rpds-py" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, -] - -[[package]] -name = "regex" -version = "2026.5.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/50/9b/6550044bc44e17c84d312c031c2ec42fbdb6a4ec4e29093be3a172d08772/regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06", size = 490451, upload-time = "2026-05-09T23:12:34.72Z" }, - { url = "https://files.pythonhosted.org/packages/1e/95/fc7ba4303b5a0f92446a12ee6778ef2c6c799233f5060042a31bf390cfe9/regex-2026.5.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6", size = 292112, upload-time = "2026-05-09T23:12:36.285Z" }, - { url = "https://files.pythonhosted.org/packages/54/4b/ee27938d1b2c443e89a9a10e00d2d19aa5ee300cd3d61140644e93bb083e/regex-2026.5.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225", size = 289599, upload-time = "2026-05-09T23:12:38.089Z" }, - { url = "https://files.pythonhosted.org/packages/d8/dd/ba103dc19614e25f3880800ca67ce093d6e21b325d72b8383c7bf906e9fa/regex-2026.5.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0", size = 796732, upload-time = "2026-05-09T23:12:40.062Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e7/f035b4fd858b050b0080bf302968dc0f59ba34e391872d54936758e6844e/regex-2026.5.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107", size = 865440, upload-time = "2026-05-09T23:12:42.059Z" }, - { url = "https://files.pythonhosted.org/packages/0a/51/8cd301ecc899aea28124357f729f4272f44de7806fc7ca02490bfbe253e8/regex-2026.5.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309", size = 912329, upload-time = "2026-05-09T23:12:44.373Z" }, - { url = "https://files.pythonhosted.org/packages/cc/1e/3fbe2fa1e8cebd62f3bb7d3321cff1640aca2e240b51d9bd624aad949260/regex-2026.5.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8", size = 801239, upload-time = "2026-05-09T23:12:46.268Z" }, - { url = "https://files.pythonhosted.org/packages/17/2f/6f6008682bf2cf98040a0d3153a8e557b6ab728d7713d045cee4ce544ab8/regex-2026.5.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66", size = 777054, upload-time = "2026-05-09T23:12:48.051Z" }, - { url = "https://files.pythonhosted.org/packages/19/2b/eee0d20a6842ba04df4b8847a920b57ef56853f14ef85405473e586b605a/regex-2026.5.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026", size = 785098, upload-time = "2026-05-09T23:12:49.851Z" }, - { url = "https://files.pythonhosted.org/packages/4a/98/6fc1e6410feefb92159edaed5041992bfe390e8d26c721865434acbca558/regex-2026.5.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962", size = 860095, upload-time = "2026-05-09T23:12:51.666Z" }, - { url = "https://files.pythonhosted.org/packages/18/a3/bd855e0f2cb1a978ecf6fa6bb69632dd9c3f6ea3b81cde62fde14c9daec7/regex-2026.5.9-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621", size = 765762, upload-time = "2026-05-09T23:12:53.413Z" }, - { url = "https://files.pythonhosted.org/packages/dc/66/0ae8c092e60b14c79d24f8e0b7f0aea5bfbffdcab00b5483d13404d3c3a5/regex-2026.5.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d", size = 852100, upload-time = "2026-05-09T23:12:55.256Z" }, - { url = "https://files.pythonhosted.org/packages/21/de/8dfde60fc1b21c946a893ba273403b72617edb261370cb1087099a83f088/regex-2026.5.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce", size = 789479, upload-time = "2026-05-09T23:12:57.573Z" }, - { url = "https://files.pythonhosted.org/packages/c3/1c/bdcc98f9a4af4fdd166c74941174619ccff4726d3ce32faa8e9a2ecd38dd/regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e", size = 266699, upload-time = "2026-05-09T23:12:59.14Z" }, - { url = "https://files.pythonhosted.org/packages/78/87/240d36864f9e48ace85f72e79ced97ceb7f27ce87739a947dcb834b4e6bc/regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e", size = 277783, upload-time = "2026-05-09T23:13:00.789Z" }, - { url = "https://files.pythonhosted.org/packages/4f/b5/7b30f312b0669dff5beebe5b0989dc2d1a312b1a44fab852199c387a5b96/regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070", size = 270513, upload-time = "2026-05-09T23:13:02.426Z" }, - { url = "https://files.pythonhosted.org/packages/aa/da/797e91ecec6f84135da778ddce78c20e0af5d2a15c26f87a81bc3eadb6db/regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb", size = 490303, upload-time = "2026-05-09T23:13:04.382Z" }, - { url = "https://files.pythonhosted.org/packages/44/da/bf30abaaa737b58f4a4b8c4a03659e02fd92092c822e0197ed9e0daab917/regex-2026.5.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f", size = 292019, upload-time = "2026-05-09T23:13:06.022Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e7/d0eaf5713828417b9e5648cf81fa9bacd4961f6ab98c380c2034f8716e35/regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c", size = 289468, upload-time = "2026-05-09T23:13:08.214Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9b/b3fdd62b003baa1a9b593cd8c8699c9651c2e80cc21a5c715707983c42d7/regex-2026.5.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed", size = 796749, upload-time = "2026-05-09T23:13:10.573Z" }, - { url = "https://files.pythonhosted.org/packages/d4/30/66ab84588765f5b4b271a9ca09ef7ce2b87caa95176ec3d2ad65d7bc4902/regex-2026.5.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020", size = 865445, upload-time = "2026-05-09T23:13:12.523Z" }, - { url = "https://files.pythonhosted.org/packages/1a/89/f05169e8588aac365f35ffc7f3bc3184f095ef4cfded7cfaa3c7fd5dbd89/regex-2026.5.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2", size = 912322, upload-time = "2026-05-09T23:13:14.281Z" }, - { url = "https://files.pythonhosted.org/packages/30/e1/c93444052cf41581f3c884ab3fb5823daf0992f11cd4388d4275ca610558/regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2", size = 801269, upload-time = "2026-05-09T23:13:16.569Z" }, - { url = "https://files.pythonhosted.org/packages/50/fe/0cf96b882f540e62e8b9956599798203d599c44cf4c77917ca27400ff69b/regex-2026.5.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04", size = 777085, upload-time = "2026-05-09T23:13:18.675Z" }, - { url = "https://files.pythonhosted.org/packages/23/5c/d78d4924e7fc875557b9e9b768423925fdfaac5549d06da7810019a9bd26/regex-2026.5.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c", size = 785153, upload-time = "2026-05-09T23:13:20.525Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e0/5214774090e7b4524dcea3e3c4aa74141d43043f8beb49c1599db1c8b53a/regex-2026.5.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f", size = 860164, upload-time = "2026-05-09T23:13:22.263Z" }, - { url = "https://files.pythonhosted.org/packages/6e/e1/4a57a83350319b1271f0d7a249b8672513ed928b237a741631270de6caea/regex-2026.5.9-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8", size = 765731, upload-time = "2026-05-09T23:13:24.277Z" }, - { url = "https://files.pythonhosted.org/packages/12/f4/499e74a20c156fc75836ee04a72a38d1a063978f600937f9760467beb1b0/regex-2026.5.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6", size = 852062, upload-time = "2026-05-09T23:13:26.125Z" }, - { url = "https://files.pythonhosted.org/packages/5b/92/7eebc0d0a01e78629695f342ba17e0deaff8fb45e79cc0d7b98287da6e3e/regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21", size = 789577, upload-time = "2026-05-09T23:13:27.814Z" }, - { url = "https://files.pythonhosted.org/packages/05/a4/018e71f7d2ad48c1ebe6d3ae0026f9b7cb4802fd15c7cc02fdf724355102/regex-2026.5.9-cp313-cp313-win32.whl", hash = "sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127", size = 266691, upload-time = "2026-05-09T23:13:29.549Z" }, - { url = "https://files.pythonhosted.org/packages/e6/1d/861a93719fb9ee7dbfc3761b3797b7a3e112a5d42c6129459d2d741be9b5/regex-2026.5.9-cp313-cp313-win_amd64.whl", hash = "sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca", size = 277747, upload-time = "2026-05-09T23:13:31.859Z" }, - { url = "https://files.pythonhosted.org/packages/d9/c6/0a2436ae4da1ba76e51cb98943c6838a9a721faa40ebe2dce07694ae34e3/regex-2026.5.9-cp313-cp313-win_arm64.whl", hash = "sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6", size = 270500, upload-time = "2026-05-09T23:13:33.525Z" }, - { url = "https://files.pythonhosted.org/packages/e8/e9/d21346f7b60ed58789371358ed66b09d00f832e1bd7c06e55d9da5679882/regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3", size = 494172, upload-time = "2026-05-09T23:13:35.935Z" }, - { url = "https://files.pythonhosted.org/packages/c4/43/fd1177a2032037c681baecdb3422ee4e1424aec4e4f470ef47793d325274/regex-2026.5.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6", size = 293952, upload-time = "2026-05-09T23:13:38.307Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7d/9fbf919768368d3f8a4f6c692cf2aa61e482b2b81ec6a298ace4cbf02480/regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff", size = 292314, upload-time = "2026-05-09T23:13:40.353Z" }, - { url = "https://files.pythonhosted.org/packages/e2/6c/e41bfeecb589716843e7c4df09ba46ff2a42961457afece19059d85caeef/regex-2026.5.9-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88", size = 811681, upload-time = "2026-05-09T23:13:42.543Z" }, - { url = "https://files.pythonhosted.org/packages/87/83/a5c1c525fba0aa656e88ad0face0b1829788ef4c2fb6b26df58aa1151b84/regex-2026.5.9-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178", size = 871135, upload-time = "2026-05-09T23:13:44.326Z" }, - { url = "https://files.pythonhosted.org/packages/18/d4/80882e799e440dd878b0979cbebf8fa4d54624a332c83037c7a701649e3f/regex-2026.5.9-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100", size = 917265, upload-time = "2026-05-09T23:13:47.295Z" }, - { url = "https://files.pythonhosted.org/packages/ae/ff/8db60211e2286e396aad7dc7725356c502bff0901ea05bd6cdc2e1a042b9/regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e", size = 816311, upload-time = "2026-05-09T23:13:49.885Z" }, - { url = "https://files.pythonhosted.org/packages/4c/47/742ef579c61730f8d268e5cf1f9ce0e37e2ea041ad0f5644724f2378e463/regex-2026.5.9-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2", size = 785498, upload-time = "2026-05-09T23:13:52.25Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ab/cb0999802dcb0fb95b1ab005e8d4163d8afdd67efc2cb6b6630ac13f8cb1/regex-2026.5.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b", size = 801348, upload-time = "2026-05-09T23:13:54.127Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/8ca59a24c55bc34d166eefaf3717bd77772f329fdbf984d86581e0a3571c/regex-2026.5.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e", size = 866493, upload-time = "2026-05-09T23:13:56.067Z" }, - { url = "https://files.pythonhosted.org/packages/8d/3d/30f2ae62cef3278bb5bb821f467277a55fb73f01032cf85997e15e8289a8/regex-2026.5.9-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041", size = 772811, upload-time = "2026-05-09T23:13:57.867Z" }, - { url = "https://files.pythonhosted.org/packages/d8/ae/7d2089bcd78ad0c0161bc684339df50032acb438a7bd3305e7ddb1193cec/regex-2026.5.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0", size = 856584, upload-time = "2026-05-09T23:13:59.679Z" }, - { url = "https://files.pythonhosted.org/packages/a9/29/92ff47f75990131ea4f24ba17819e5a9d141e10819807e09addd73409af6/regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081", size = 803453, upload-time = "2026-05-09T23:14:01.978Z" }, - { url = "https://files.pythonhosted.org/packages/04/99/eff29f1037dcab36702c9ee5d6858cf1ce2336ea8ea2987f64245b99ea5e/regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5", size = 269951, upload-time = "2026-05-09T23:14:03.661Z" }, - { url = "https://files.pythonhosted.org/packages/0e/9d/8870b8981d27b22cda77bb26a5ac7ebfa9c7d9e0dea195a834a82380e748/regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4", size = 281240, upload-time = "2026-05-09T23:14:05.56Z" }, - { url = "https://files.pythonhosted.org/packages/72/b1/3379415e8f135c13ac551353397cc4fe97b4978f3cac73c5fcbcded548b8/regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de", size = 272383, upload-time = "2026-05-09T23:14:07.843Z" }, - { url = "https://files.pythonhosted.org/packages/13/3e/9c3cd292d8808b3645a2ce517e200179b6d0e903f176300bd8b542e14de5/regex-2026.5.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a", size = 490376, upload-time = "2026-05-09T23:14:09.64Z" }, - { url = "https://files.pythonhosted.org/packages/60/70/d43ee8a2ca0a8b68d167f21658b85520ac0574617c7f320367c5047f7556/regex-2026.5.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4", size = 291964, upload-time = "2026-05-09T23:14:11.424Z" }, - { url = "https://files.pythonhosted.org/packages/21/91/9d50b433828d8e74196904e168a43abf1e6e88b2a15d47ed742456720c37/regex-2026.5.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c", size = 289682, upload-time = "2026-05-09T23:14:13.123Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/b835e3cafbb9d977736912436259ff551d60919f7d7b3d37d46659c63564/regex-2026.5.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9", size = 796996, upload-time = "2026-05-09T23:14:14.923Z" }, - { url = "https://files.pythonhosted.org/packages/2c/a6/9f992d00019166b9de01c546dd4549bc679f2a68df11b877740b0760b7c2/regex-2026.5.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af", size = 866089, upload-time = "2026-05-09T23:14:17.757Z" }, - { url = "https://files.pythonhosted.org/packages/e0/08/4d32af657e049b19cb62b02e46e38fe1518797bfb2203ee93a510b21b0dc/regex-2026.5.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0", size = 911530, upload-time = "2026-05-09T23:14:20.353Z" }, - { url = "https://files.pythonhosted.org/packages/d9/27/2af43dd1dc201d1fecefda64a45f4ad0995855b92724f795a777b402ee69/regex-2026.5.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4", size = 800643, upload-time = "2026-05-09T23:14:22.265Z" }, - { url = "https://files.pythonhosted.org/packages/a4/dd/23a249047013b5321d4a60c4d2437462086f601b061776a525e5fba2a59f/regex-2026.5.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf", size = 777223, upload-time = "2026-05-09T23:14:24.179Z" }, - { url = "https://files.pythonhosted.org/packages/94/6a/e85ed9538cd19586d0465076a4578a12e093ce776d15f3f8ce92733a8dd6/regex-2026.5.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346", size = 785760, upload-time = "2026-05-09T23:14:26.065Z" }, - { url = "https://files.pythonhosted.org/packages/2a/c4/f25473209438638e947c55f9156fd8f236f74169229028cc99116380868e/regex-2026.5.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676", size = 860891, upload-time = "2026-05-09T23:14:28.17Z" }, - { url = "https://files.pythonhosted.org/packages/f9/f7/f4f86e3c74419c37370e91f150ae0c2ef7d34b2e0e4cdd5da046a02e4022/regex-2026.5.9-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14", size = 765891, upload-time = "2026-05-09T23:14:30.06Z" }, - { url = "https://files.pythonhosted.org/packages/26/70/704d8e13765939146b1cd0ef4e2feb71d7929727d2290f026eed10095955/regex-2026.5.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd", size = 851380, upload-time = "2026-05-09T23:14:32.123Z" }, - { url = "https://files.pythonhosted.org/packages/26/29/1a13582a8460038edc38e49f64ceb0dd7c60f5caba77571f4bf6601965d9/regex-2026.5.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e", size = 789350, upload-time = "2026-05-09T23:14:34.799Z" }, - { url = "https://files.pythonhosted.org/packages/73/56/3dcafe34fc72e271d62ad9a291801e88a1457bb251c132f15fcc2e5aad1a/regex-2026.5.9-cp314-cp314-win32.whl", hash = "sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad", size = 272130, upload-time = "2026-05-09T23:14:36.729Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9c/02eebf0be95efe416c664db7fb8b6b05b7a0b06a7544f2884f2558b0526f/regex-2026.5.9-cp314-cp314-win_amd64.whl", hash = "sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763", size = 280999, upload-time = "2026-05-09T23:14:39.126Z" }, - { url = "https://files.pythonhosted.org/packages/70/5a/1dd1abee76cb7a846a0bcf42fdc87e5720c3c33c24f3e37814310a513d9f/regex-2026.5.9-cp314-cp314-win_arm64.whl", hash = "sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372", size = 273500, upload-time = "2026-05-09T23:14:41.059Z" }, - { url = "https://files.pythonhosted.org/packages/86/c1/c5f619b0057a7965cb78ec559c1d7a45ce8c99a35bea95483d64959a93d9/regex-2026.5.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499", size = 494269, upload-time = "2026-05-09T23:14:42.869Z" }, - { url = "https://files.pythonhosted.org/packages/05/2c/5d01f1aee33de4bbe60c8452945bfc8477ca7c5ae4450f6bfe711036cb36/regex-2026.5.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1", size = 293954, upload-time = "2026-05-09T23:14:44.822Z" }, - { url = "https://files.pythonhosted.org/packages/7a/fe/e8988b2ae2108c6ef71bd4aa8d87fbe257976dd0810e826cd75f701c68b6/regex-2026.5.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d", size = 292405, upload-time = "2026-05-09T23:14:47.211Z" }, - { url = "https://files.pythonhosted.org/packages/79/34/d2b0937faa7859263f7f0a3c6b103a1296306be6952dc173d0154e9a2f49/regex-2026.5.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c", size = 811855, upload-time = "2026-05-09T23:14:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/80/fe/daf53a47457a8486db66c66c01ceb9c2303eecee3f87197f1e77eb1a736d/regex-2026.5.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5", size = 871189, upload-time = "2026-05-09T23:14:51.555Z" }, - { url = "https://files.pythonhosted.org/packages/1c/75/058fc4470cbfbf57d800aff1a0022b929a3f9fa553ee10a0cdf2070eb31f/regex-2026.5.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20", size = 917485, upload-time = "2026-05-09T23:14:53.633Z" }, - { url = "https://files.pythonhosted.org/packages/88/e7/179cfda3a28bc843b5c6cfe7f79f23489c791ed95f151083803660878432/regex-2026.5.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0", size = 816369, upload-time = "2026-05-09T23:14:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/41/90/6f0cc422071688266d344fca8462d787cba0a2c144acb25721f9a61ec265/regex-2026.5.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d", size = 785869, upload-time = "2026-05-09T23:14:58.602Z" }, - { url = "https://files.pythonhosted.org/packages/02/67/a31f1760f09c27b251ef39e9beb541f462cf977381d067faa764c2c0e393/regex-2026.5.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b", size = 801427, upload-time = "2026-05-09T23:15:00.642Z" }, - { url = "https://files.pythonhosted.org/packages/e3/c4/1a80654597b6bc1e1ea0494824c31200e8a956abe290afae9b19a166a148/regex-2026.5.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a", size = 866482, upload-time = "2026-05-09T23:15:03.384Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/960724e06482c08466ff5611e242e86f80062949cdf6b4b9cc317b9dd93d/regex-2026.5.9-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415", size = 773022, upload-time = "2026-05-09T23:15:05.625Z" }, - { url = "https://files.pythonhosted.org/packages/50/a8/a9979c3e7918280e93159ebcab5ef1a65116dd4f3bd6091be0eae4a126e8/regex-2026.5.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2", size = 856642, upload-time = "2026-05-09T23:15:07.966Z" }, - { url = "https://files.pythonhosted.org/packages/fe/d4/a9b732f2f0072c0ab12227483abb24fffcb9f73f8a2b203df0a6d0434735/regex-2026.5.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41", size = 803552, upload-time = "2026-05-09T23:15:10.215Z" }, - { url = "https://files.pythonhosted.org/packages/d5/fe/1b3113817447a1d4155e4ac76d2e072f42c0bcba2f43fa8a0e756ea2cd91/regex-2026.5.9-cp314-cp314t-win32.whl", hash = "sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58", size = 275746, upload-time = "2026-05-09T23:15:12.609Z" }, - { url = "https://files.pythonhosted.org/packages/92/73/93d42045302636c91f2e5ef588b65b84b01428f28ec77de256b1dfdfbe5c/regex-2026.5.9-cp314-cp314t-win_amd64.whl", hash = "sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77", size = 285685, upload-time = "2026-05-09T23:15:15.086Z" }, - { url = "https://files.pythonhosted.org/packages/da/80/35b4c33c804a165a7f55289afda3ea9e3eb6d15800341a2d66455c0f1f30/regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa", size = 275713, upload-time = "2026-05-09T23:15:16.98Z" }, -] - -[[package]] -name = "requests" -version = "2.34.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, -] - -[[package]] -name = "requests-oauthlib" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "oauthlib" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, -] - -[[package]] -name = "requests-toolbelt" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, -] - [[package]] name = "respx" version = "0.22.0" @@ -2625,125 +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-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-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-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-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" } -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" } +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/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/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]] @@ -2772,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" @@ -2834,135 +1410,141 @@ 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/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-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-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-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2'", + "python_full_version >= '3.10' and python_full_version < '3.14' and extra != 'group-14-agentex-client-pydantic-v1' and extra == 'group-14-agentex-client-pydantic-v2'", + "python_full_version >= '3.10' and extra == 'group-14-agentex-client-pydantic-v1' and extra != 'group-14-agentex-client-pydantic-v2'", + "python_full_version >= '3.10' and extra != 'group-14-agentex-client-pydantic-v1' and extra != 'group-14-agentex-client-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" }, + { url = "https://files.pythonhosted.org/packages/cf/ad/d17d83a59943094e6b6c6a3743caaf6811b12203c3e07a30cc7bcc2ab7ee/time_machine-3.2.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98493cd50e8b7f941eab69b9e18e697ad69db1a0ec1959f78f3d7b0387107e5c", size = 32632, upload-time = "2025-12-17T23:31:47.72Z" }, + { url = "https://files.pythonhosted.org/packages/71/50/d60576d047a0dfb5638cdfb335e9c3deb6e8528544fa0b3966a8480f72b7/time_machine-3.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31f2a33d595d9f91eb9bc7f157f0dc5721f5789f4c4a9e8b852cdedb2a7d9b16", size = 34289, upload-time = "2025-12-17T23:31:48.913Z" }, + { url = "https://files.pythonhosted.org/packages/fa/fe/4afa602dbdebddde6d0ea4a7fe849e49b9bb85dc3fb415725a87ccb4b471/time_machine-3.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9f78ac4213c10fbc44283edd1a29cfb7d3382484f4361783ddc057292aaa1889", size = 33175, upload-time = "2025-12-17T23:31:50.611Z" }, + { url = "https://files.pythonhosted.org/packages/0d/87/c152e23977c1d7d7c94eb3ed3ea45cc55971796205125c6fdff40db2c60f/time_machine-3.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c1326b09e947b360926d529a96d1d9e126ce120359b63b506ecdc6ee20755c23", size = 31170, upload-time = "2025-12-17T23:31:51.645Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/54acf51d0f3ade3b51eab73df6192937c9a938753ef5456dff65eb8630be/time_machine-3.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9f2949f03d15264cc15c38918a2cda8966001f0f4ebe190cbfd9c56d91aed8ac", size = 32292, upload-time = "2025-12-17T23:31:52.803Z" }, + { url = "https://files.pythonhosted.org/packages/cc/bc/3745963f36e75661a807196428639327a366f4332f35f1f775c074d4062f/time_machine-3.2.0-cp311-cp311-win32.whl", hash = "sha256:6dfe48e0499e6e16751476b9799e67be7514e6ef04cdf39571ef95a279645831", size = 17349, upload-time = "2025-12-17T23:31:54.19Z" }, + { url = "https://files.pythonhosted.org/packages/82/a2/057469232a99d1f5a0160ae7c5bae7b095c9168b333dd598fcbcfbc1c87b/time_machine-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:809bdf267a29189c304154873620fe0bcc0c9513295fa46b19e21658231c4915", size = 18191, upload-time = "2025-12-17T23:31:55.472Z" }, + { url = "https://files.pythonhosted.org/packages/79/d8/bf9c8de57262ee7130d92a6ed49ed6a6e40a36317e46979428d373630c12/time_machine-3.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:a3f4c17fa90f54902a3f8692c75caf67be87edc3429eeb71cb4595da58198f8e", size = 16905, upload-time = "2025-12-17T23:31:56.658Z" }, { url = "https://files.pythonhosted.org/packages/71/8b/080c8eedcd67921a52ba5bd0e075362062509ab63c86fc1a0442fad241a6/time_machine-3.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc4bee5b0214d7dc4ebc91f4a4c600f1a598e9b5606ac751f42cb6f6740b1dbb", size = 19255, upload-time = "2025-12-17T23:31:58.057Z" }, { url = "https://files.pythonhosted.org/packages/66/17/0e5291e9eb705bf8a5a1305f826e979af307bbeb79def4ddbf4b3f9a81e0/time_machine-3.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ca036304b4460ae2fdc1b52dd8b1fa7cf1464daa427fc49567413c09aa839c1", size = 15360, upload-time = "2025-12-17T23:31:59.048Z" }, { url = "https://files.pythonhosted.org/packages/8b/e8/9ab87b71d2e2b62463b9b058b7ae7ac09fb57f8fcd88729dec169d304340/time_machine-3.2.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5442735b41d7a2abc2f04579b4ca6047ed4698a8338a4fec92c7c9423e7938cb", size = 33029, upload-time = "2025-12-17T23:32:00.413Z" }, @@ -3021,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]] @@ -3127,398 +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-14-agentex-client-pydantic-v2' or extra != 'group-14-agentex-client-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/ff/4c/b4cf43a5d22bcdb91727acdf54be0d78e83e595b73c5a9a8a4291875f059/uuid_utils-0.16.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:727fae3f0682191ec9c8ce1cd0f71e81b471a2e26b7c5fd66712fc0f11640aa0", size = 562183, upload-time = "2026-05-19T07:45:02.683Z" }, - { url = "https://files.pythonhosted.org/packages/d6/fb/4b0d1c4b5e9f8679ca41b9cdbce5749e1d5db3d3d42a07060d6ce61ac583/uuid_utils-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:66a9c8cedf7695c28e700f6a66bde0809c3b2e0d8a70968be7bfd47c908952e5", size = 289018, upload-time = "2026-05-19T07:44:07.726Z" }, - { url = "https://files.pythonhosted.org/packages/de/43/2dc6c7401c8fab86e46b0b33ada6dcfde949b2fd48877ba6f880862be80e/uuid_utils-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9152bff801ec2ccf630df06d67389090a2c612dea87fbf9a887ab4b222929f6f", size = 326171, upload-time = "2026-05-19T07:45:25.186Z" }, - { url = "https://files.pythonhosted.org/packages/9b/f5/48f11fb91f36453611ca148bc441436f279870b1ec6b576dc5167fb6e680/uuid_utils-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:06fc7db470c37e5c1ab3fd2cd159697d6f8b279d7d23b5b96bd418b115f8caa9", size = 332222, upload-time = "2026-05-19T07:45:09.036Z" }, - { url = "https://files.pythonhosted.org/packages/30/cb/b2b49528521e4a097f129e8bf7850a26f00af46afba778832cf3458a5c00/uuid_utils-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e1a1f57fe3631e164dad27b24aa81267810e20575f705af3b0fa734f3a21247", size = 444801, upload-time = "2026-05-19T07:45:37.517Z" }, - { url = "https://files.pythonhosted.org/packages/a9/b3/a28d9c6f7c701dfe01c8020b30e33899a28eb9e4d056b07e7388f50ebf67/uuid_utils-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ee392fe59808a731b7b6bf4d453fb6e833774921331cceae5f254d1e9c5b97d", size = 325594, upload-time = "2026-05-19T07:44:44.682Z" }, - { url = "https://files.pythonhosted.org/packages/cf/65/e1ff41dc44966e396ead86e104ba21b35ddb07ff7a64bb55013074ee77fe/uuid_utils-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b2e981b1258db444df4cf4bf4c79673570d081d48d35f22d0f86471e0ad795c5", size = 349312, upload-time = "2026-05-19T07:45:15.582Z" }, - { url = "https://files.pythonhosted.org/packages/ed/57/fb19b7951f66a46e03bd1943a61ee9d59c83e994e56e8c97d79aff1f0e47/uuid_utils-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbb92feb4db08cd76e27b4d3b1a82bfde708447317150c614eb9f761a43b387e", size = 502115, upload-time = "2026-05-19T07:43:38.756Z" }, - { url = "https://files.pythonhosted.org/packages/2f/8e/9a129c469b7b77afb62da5c6b7e92591073b845bd0c3108c0d0aa65389fb/uuid_utils-0.16.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c3c5afaaa68b1d6393d653e9fc93a2fde9da1681da01f74b4593f41d31fb5f1", size = 607433, upload-time = "2026-05-19T07:44:11.675Z" }, - { url = "https://files.pythonhosted.org/packages/4a/56/2ef71fad168cc3d894f7094fa458086c093635d7835381c91470b19c9ad3/uuid_utils-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:38126b353527c5f001e4b24db9e62351eb768d0367febcd68100a4b39a035109", size = 566076, upload-time = "2026-05-19T07:44:35.453Z" }, - { url = "https://files.pythonhosted.org/packages/95/bf/68e60ea053ca30f35df877b96001331398140d5c4983561affa1350331b1/uuid_utils-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41a67e546d9adf11c4e4cb5c8e81f000f8b1f000c17912ced089b499855719a5", size = 530645, upload-time = "2026-05-19T07:45:49.278Z" }, - { url = "https://files.pythonhosted.org/packages/42/19/b521f7d73094fca4c0c44002f4a42bfcbcf0b770fdc3c4b9a596dda25734/uuid_utils-0.16.0-cp312-cp312-win32.whl", hash = "sha256:52d2cc8c12a3466cd1727883e0746d8bad5dddd670369eb553ba17fdc3b565ca", size = 168887, upload-time = "2026-05-19T07:45:45.502Z" }, - { url = "https://files.pythonhosted.org/packages/87/1f/4126c3ccbc2d98a613664e55f6ab6d7bd4b98424a04486e4fcc76549af15/uuid_utils-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97625e5edfda8b118160ce1e88756f92b1635775f836c168be7bf10928d97fa", size = 174607, upload-time = "2026-05-19T07:43:52.938Z" }, - { url = "https://files.pythonhosted.org/packages/74/62/b83ccc8446ae39dcc0bda2cb3b525b6af6a2036383afe1d1d5fe7b234c2c/uuid_utils-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:baf79c8050eb784b252dd34807df73f61130fe8676b61231baccab62530f20ec", size = 173021, upload-time = "2026-05-19T07:45:10.204Z" }, - { url = "https://files.pythonhosted.org/packages/60/9b/74c1f47a9b4f138a254e51528e5ffaeba6bf99ecead9f0c4b6fccccfbfcb/uuid_utils-0.16.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d34cf9681e8892fad2a63e393068e544505408748cd8bf0c3517d753a01528d4", size = 563166, upload-time = "2026-05-19T07:44:10.494Z" }, - { url = "https://files.pythonhosted.org/packages/7c/1c/009e37b70f1f0ff17e7103a36bafde33d503d9ea7fe739761aa3e3c9fde6/uuid_utils-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0681d1bdb7956e0c6d581e7601dabcfb2b08c25d2a65189f4e9b102c94f5ff46", size = 289529, upload-time = "2026-05-19T07:43:54.466Z" }, - { url = "https://files.pythonhosted.org/packages/5e/5e/e0323d54321166639eb2be5e8a464f5cb0fc04d72d91f3e78944bb6a1da8/uuid_utils-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed45fb8732d216426227096b55accbb87cba57febc86a044d90780b090eb99d0", size = 326328, upload-time = "2026-05-19T07:45:31.901Z" }, - { url = "https://files.pythonhosted.org/packages/f0/a3/046f6cb958467c3bf4a163a8a53b178b64a62e21ed8ad5b2c1dacb3a2cfc/uuid_utils-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b617a334bb01ef2ff8c22900f5a14125eb9063f602131494cc9dc59519beaa5b", size = 332322, upload-time = "2026-05-19T07:43:41.284Z" }, - { url = "https://files.pythonhosted.org/packages/67/80/01914e3949744db7acd0006885e5542fbebb6e39114857d007d29b3265c2/uuid_utils-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a750d8aeb8ae880aa9a2529606bde0e994bcc7448730c953107f357a28e6102e", size = 445787, upload-time = "2026-05-19T07:45:36.102Z" }, - { url = "https://files.pythonhosted.org/packages/14/ef/f6908f41279f205d70c8a0d5dcb25dd6802741d7f88e3f0123453c3584d3/uuid_utils-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a250e111903c4368745fce5ac2aa607bd477c62d3307e45347338fdb64b38e0", size = 324678, upload-time = "2026-05-19T07:45:12.77Z" }, - { url = "https://files.pythonhosted.org/packages/11/4a/bf841ba90f829c7779d82155e0f4b88ef6726ccc25507d064d50ac2cd329/uuid_utils-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:95b7f480010ea98a29ee809857a98aa923008c68129af1b39244adccff7377fb", size = 349704, upload-time = "2026-05-19T07:44:47.172Z" }, - { url = "https://files.pythonhosted.org/packages/e6/31/3b5c60172b8c57bf4ca485484b8e4edef550ca324f9287f1183be97422e2/uuid_utils-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:420aa3ca403cedb73490b6ea3aeefeea7e0455f5ce60bbf856390ee872ae3306", size = 502456, upload-time = "2026-05-19T07:45:00.821Z" }, - { url = "https://files.pythonhosted.org/packages/88/bf/3da8d497af80fd51d8bf85551c77ede67f07825924ec5987bf9b6031014a/uuid_utils-0.16.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b8a9a7b1065a12d40f2cc25b7d705ab34954cc57095034367bca39ebcf4a876b", size = 607727, upload-time = "2026-05-19T07:44:30.058Z" }, - { url = "https://files.pythonhosted.org/packages/bd/4e/7c8cf03ec15cd6f40e4cbab81b2b4a625461327f68c7971e54723280ec3e/uuid_utils-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f235ac5827d74ac630cc87f29278cdaa5d2f273613a6e05bbd96df7aa4170776", size = 566204, upload-time = "2026-05-19T07:44:51.225Z" }, - { url = "https://files.pythonhosted.org/packages/f9/5f/af955feae69cce7fd2121ca3f790ff4b85ad2e17b2149546f50753e1a047/uuid_utils-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c8083284488b84ad178e74add64cfd1e74e8be5e30821e5acbc5019281c658b0", size = 529986, upload-time = "2026-05-19T07:45:57.85Z" }, - { url = "https://files.pythonhosted.org/packages/10/cf/3fec757e51bef10eb41ae8075f5442c60e85ff456b42d16a3063f5dc6c80/uuid_utils-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:27a071a899ba46a551d6524dbbc5a98b88be176d0f55ddf72cf71c005326ac10", size = 98683, upload-time = "2026-05-19T07:44:16.369Z" }, - { url = "https://files.pythonhosted.org/packages/40/a7/cd1adbea7ef882a70db064c00cd93b12e11027b4cdd7ffd79e95c35fc3e3/uuid_utils-0.16.0-cp313-cp313-win32.whl", hash = "sha256:924a8de04460e4cf65998ad0b6568084f7c51740ebd3254d07a0bcde35a84af6", size = 168822, upload-time = "2026-05-19T07:44:24.09Z" }, - { url = "https://files.pythonhosted.org/packages/74/99/617ceb9e3a95b23837012740979baf71afad723b70daf34862da3f7c17a1/uuid_utils-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:5279bc7ab3c6683f1c67314695bee14d869015acbbc677bdb0015190fe753d16", size = 174967, upload-time = "2026-05-19T07:44:56.022Z" }, - { url = "https://files.pythonhosted.org/packages/d9/d8/148ae707bfc36d482e39db679c86b81bdce264d4feb9df5d40a03b7687e3/uuid_utils-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:61a9c4c26ad12ac66fa4bfd0fdb8494724fe7a5b98a9fcd43e78e2b388663dbb", size = 173142, upload-time = "2026-05-19T07:43:50.171Z" }, - { url = "https://files.pythonhosted.org/packages/21/05/ca6d60705e71fdeaa3431dad94e279a8213c5573cb2925e1aabf3dc0330a/uuid_utils-0.16.0-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:73486b6aa3f755a6c97000f5ea67e7ac78d6df89bf22980789a1e943e24b74f0", size = 564408, upload-time = "2026-05-19T07:44:38.351Z" }, - { url = "https://files.pythonhosted.org/packages/eb/8c/b9a0462c38535c1662acb1025768e2d626bee5ce9e1790bad6b5381162ea/uuid_utils-0.16.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f1614572fd9345cdc3dde3f40c237345719fabca1aa87d2d87b321d523cfa34d", size = 289923, upload-time = "2026-05-19T07:45:19.611Z" }, - { url = "https://files.pythonhosted.org/packages/f2/33/a53afeef1a56051551a0f5a801e4bce411dd73c6a8c99bad16902651256d/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9346ce6eb1fbd8b03a6b331d66016afcb4edcdff6eac708e21391600529a016a", size = 325762, upload-time = "2026-05-19T07:45:18.261Z" }, - { url = "https://files.pythonhosted.org/packages/72/ca/4462a4f36365d7ee72d41e05e6bcfe127e861b073ab37c25b2c8a518317c/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a0fc6eb3fd821466fbab69cf356c6ec2b7327266bbbc740a2eb57c77c4bef965", size = 332359, upload-time = "2026-05-19T07:45:34.886Z" }, - { url = "https://files.pythonhosted.org/packages/c5/67/9d3373fa7c5a746fdecc64e30caf915c29eb632203508d87676f9243ed03/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:13a797e5e8f0dadc18351a5aa013815ddac25dce6864072a539d510910c95f71", size = 445483, upload-time = "2026-05-19T07:44:49.598Z" }, - { url = "https://files.pythonhosted.org/packages/57/08/ce01aa6d897fc7f875844fe58cad0a542c8ebf089d9242b654b56260ecb8/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57c3583b1f1c00a94f59726a5e2b988fa209221143919a1af5c2fc24e318fc98", size = 326281, upload-time = "2026-05-19T07:44:59.677Z" }, - { url = "https://files.pythonhosted.org/packages/76/ef/2c719b2c26bb5b5e5061a1435c11ad2bd33ac3cd6d4cd0c7c3ac1d3396ed/uuid_utils-0.16.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:caac9c8b1d50e8fbddc76e93bfefbef472978eb45adbfdb6289d578816992953", size = 350809, upload-time = "2026-05-19T07:45:28.076Z" }, - { url = "https://files.pythonhosted.org/packages/e0/9b/c1ed447328b32229cca38ac4c62d309eab006e5e9c4020e2056a175bc607/uuid_utils-0.16.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:91db59bad97ed2b9d2c6ed25082fe9762b2c422e694fe06786b28cf4e776ac4c", size = 502088, upload-time = "2026-05-19T07:44:09.208Z" }, - { url = "https://files.pythonhosted.org/packages/c1/e0/8442f4efe7bde72f0b4ae5f675d0c7fbe209ad0b54718b8ddf43c46c6fae/uuid_utils-0.16.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:41985e342a30e76366a8becc60bbdb07d72cd1b86ec657b1f31654e9fb1baada", size = 607631, upload-time = "2026-05-19T07:44:19.384Z" }, - { url = "https://files.pythonhosted.org/packages/f1/1e/9a9fa261edf4c972f28ae83421377e3ab8dbd0bd7db58fd316e782d09a3b/uuid_utils-0.16.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:1b0dcedf9266bf34a54d5cbe78648eaa627e02352f2a6923ed647530aea2f661", size = 567618, upload-time = "2026-05-19T07:43:58.478Z" }, - { url = "https://files.pythonhosted.org/packages/cc/f7/1bcfdb9d539bd42736dd6076470a42fbb5db23f79712c0a06aa0a3752f7b/uuid_utils-0.16.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:26fe23ab60f05de4ad70aaa5b6a4c2a7bbd43055e3dd6f6b31efba0532ac9c71", size = 530971, upload-time = "2026-05-19T07:45:06.348Z" }, - { url = "https://files.pythonhosted.org/packages/24/0c/18945f417d6bb4d0dd2b7652fe36c58c4e83bcf593b9b326b83aa40b853a/uuid_utils-0.16.0-cp313-cp313t-win32.whl", hash = "sha256:7f8cf49c05d58523a0f977cb7f11afc05791a0fa164d7303b8365a34750638e7", size = 169369, upload-time = "2026-05-19T07:44:32.581Z" }, - { url = "https://files.pythonhosted.org/packages/cc/cc/c0eb0c3fab2ed80d706369b750029143b53126809b77b36bcbb77da66bab/uuid_utils-0.16.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e99f9a8b2420b228faba23a637e96efaf5c6a678b2e225870f24431c82707f50", size = 175384, upload-time = "2026-05-19T07:45:56.623Z" }, - { url = "https://files.pythonhosted.org/packages/b7/77/50ac87b6e18b1c686f700aa38c9471a990683c6a955f71ac1a6677ed8145/uuid_utils-0.16.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6853b627983aa1b4fd95aa52d9e87136eb94a7b3b7de0fbb1db8a498d457eeec", size = 564108, upload-time = "2026-05-19T07:43:55.609Z" }, - { url = "https://files.pythonhosted.org/packages/83/16/65046676de246bb5334d9f58aa96d2feb9fc347fda3556aaff7da1c2fc7a/uuid_utils-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:f44b65ae0c329843817d9c90e36a7a3c677b413bf407c99e67db874dac49dad3", size = 289967, upload-time = "2026-05-19T07:45:38.886Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/54fa988606a15dfd2028e925d8eb9c3ee6edbf1eb7692a67b37282880b56/uuid_utils-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de8a365795a76f347f5622621c2bee543cffa0c70949f3ee093bdefc9d926dcc", size = 325835, upload-time = "2026-05-19T07:44:42.02Z" }, - { url = "https://files.pythonhosted.org/packages/d5/1b/50622f967ceacea1f89fd065d9bfd395b51acb02cfb0a4ddc8fa9ff0c983/uuid_utils-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:426a8c9af90242d879706ccf29da56f0b0712e7739fb0bbe16baacabc75596e2", size = 332607, upload-time = "2026-05-19T07:43:42.42Z" }, - { url = "https://files.pythonhosted.org/packages/12/f5/4059706be6617e2787e375ea52994ce3c3fa3920b7d4a9c8ebf7895681a5/uuid_utils-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:833bc4b3c3fc24be541f67b01b4a75b6b9942a9b7137395b4eb35435948bd6da", size = 444287, upload-time = "2026-05-19T07:43:37.106Z" }, - { url = "https://files.pythonhosted.org/packages/65/d5/f44b2710563da687a368f0ce4dcbd462dfb6708bcd46439d831991d595c7/uuid_utils-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efb5252d7c00d586077f10e169d6e6d0b0d0f806d8a085073f0d19b4737aef4e", size = 324949, upload-time = "2026-05-19T07:45:33.175Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a7/a69e859e37d26c5603f0bc0ae481860f691224f140e5a832f325b804770d/uuid_utils-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b3377ce388fd7bf8d231ec9d1d4f58c8e87888ddea93581f60ed6f878a4f722", size = 349651, upload-time = "2026-05-19T07:43:59.998Z" }, - { url = "https://files.pythonhosted.org/packages/db/73/4139cd3ca7b81ea283c1c8769373e9b2008241c0744a8ffb25f0a1b31325/uuid_utils-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:12b6310beb38adc173ec5dc89e98812fd7e3d98f87f3ef01d2ea6ecb5d87994f", size = 502326, upload-time = "2026-05-19T07:45:40.292Z" }, - { url = "https://files.pythonhosted.org/packages/cb/8c/858101583fbad1b3fa04da88b1f7170836aa0f00b4cb712063325c44466d/uuid_utils-0.16.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a49b5a75497643479c919e2e537a4a36224ac3aaa0fada61b75d87024021ac3e", size = 607689, upload-time = "2026-05-19T07:44:48.355Z" }, - { url = "https://files.pythonhosted.org/packages/5e/bd/8f3d54a4763dd91ebd0f3d7b0c2ec434e4e0b1fc667b03a44d611a465ec6/uuid_utils-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:63bfdf00be51b6b3b79275d6767d034ea5c7a0caa067a35d72861284100cb60a", size = 566214, upload-time = "2026-05-19T07:44:53.519Z" }, - { url = "https://files.pythonhosted.org/packages/54/76/4c9a8d9baaa243c7902d84dbba4d51b1ab51c379c66d3fd6368ff6933ecf/uuid_utils-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7525bc59ac4579c32317d2493dd42cf134b9bb50cd0bc6a41dd9f77e4740dde6", size = 529989, upload-time = "2026-05-19T07:44:43.141Z" }, - { url = "https://files.pythonhosted.org/packages/6d/13/d32cea997f880cedde415730ce0e872ebfd7a040155ae0bbda70eccd208e/uuid_utils-0.16.0-cp314-cp314-win32.whl", hash = "sha256:fbcac6e6710aa2e4bfbb81762758e01470dc56d5048ba4253acc77c9833568ff", size = 169146, upload-time = "2026-05-19T07:45:46.655Z" }, - { url = "https://files.pythonhosted.org/packages/1c/19/9fc55172d8fe59e1f27a14d598b427fa508a7ebb35fa7b7b99c24fa0ef13/uuid_utils-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:d23fcaf37368a1647319187ef6f8b741bf079f033065899bc2d00a44b0a1214a", size = 175364, upload-time = "2026-05-19T07:45:55.335Z" }, - { url = "https://files.pythonhosted.org/packages/89/5d/fcd9226b715c5aa0638fcdd6deaf0de6c6c3c451c692cd76bfca810c6512/uuid_utils-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:ea3265f8e2b452a4870f3298cb1d183dc4e36a3682cbb264dbe46af31267e706", size = 173268, upload-time = "2026-05-19T07:44:31.19Z" }, - { url = "https://files.pythonhosted.org/packages/c1/64/97ec9af95e58b8187f2934008ffab26e1604d149e34fe01c388b0543a24f/uuid_utils-0.16.0-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:99f8420c3ed59f89a086782ac197e257f4b1debb4545dffa90cf5db23f96c892", size = 564464, upload-time = "2026-05-19T07:44:40.856Z" }, - { url = "https://files.pythonhosted.org/packages/3e/6d/e4082f407484ac28923c0bf8e861e71d277118d8b7542d0a350340e45350/uuid_utils-0.16.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:259bab73c241743d684dcc3507feb76f484d720545e4e4805582aeff8e19700b", size = 290087, upload-time = "2026-05-19T07:44:01.084Z" }, - { url = "https://files.pythonhosted.org/packages/8c/43/c5c5f273c0ff889f20f10344784f9197dd00eb81ccc294330d4b949fea7e/uuid_utils-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:897e8ef0dc5e4ac0b17cf9cae84bb41e560d806280ec5b93db7475b504022105", size = 325532, upload-time = "2026-05-19T07:43:47.508Z" }, - { url = "https://files.pythonhosted.org/packages/13/7f/669aa899ab5378374d28a28231e6978f739921a1af394c7ebd6cc86e2639/uuid_utils-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c5af79cde16a7600dfccb7d431aec0afd3088ff170b6a09887bf3f7ab3cc7c81", size = 332209, upload-time = "2026-05-19T07:43:51.528Z" }, - { url = "https://files.pythonhosted.org/packages/2b/57/a2a32406d79a222794ef98a19254fd9a81a029a0f32d7740fba9873bff1f/uuid_utils-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bece1a6f677ca36047442c465d8166643eed9818b9e43e0bf42d3cf73e92dcff", size = 445507, upload-time = "2026-05-19T07:44:20.541Z" }, - { url = "https://files.pythonhosted.org/packages/26/6b/85459a35bfa7d73e79acbc4eab1cf6aa6e4d9d022c3260ed9dea539c7f0b/uuid_utils-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb3444498e7b099499c8a607d7771377020fa55f7274e46f54106af19f752d7", size = 326154, upload-time = "2026-05-19T07:45:23.587Z" }, - { url = "https://files.pythonhosted.org/packages/84/9e/e965efdbb503ed14d6e57aec1a22b98326ed24cc2fb48e750c4d192267a0/uuid_utils-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:542098f6cb6874aebeff98715f3ab7646fbe0f2ffb24509ca372828c68c4ed0e", size = 350905, upload-time = "2026-05-19T07:44:36.957Z" }, - { url = "https://files.pythonhosted.org/packages/23/ae/4321867888a783d03b7c053c0b68ca45d03974d86fcebf44d4ec268db397/uuid_utils-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7207b25fe534bcf4d57e0110f90670e61c1c38b6f4598ba855af69ab428fc118", size = 502098, upload-time = "2026-05-19T07:44:17.696Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9a/914a47bf42479bff0ce3e1fa1cbe3585354708edc928e27687cf91de9c26/uuid_utils-0.16.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:16dc5c6e439f75b0456114e955983e2156c1f38887733e54d54205d3005223e4", size = 607032, upload-time = "2026-05-19T07:44:22.151Z" }, - { url = "https://files.pythonhosted.org/packages/85/4c/2abacd6badba61a047eaa39c8347656229d12843bd9bbe4906daa6dc752c/uuid_utils-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6d3ee32c57898d8415242b08d5dd086bc4f7bcbbb3fc102ef257f3d793eb294", size = 567664, upload-time = "2026-05-19T07:45:21.043Z" }, - { url = "https://files.pythonhosted.org/packages/53/1f/9d1a09521276424da19dc0d74456aed3311170fec181b28fa6acba45d963/uuid_utils-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7555f120a2282d1901c9a632c2398a614101af4fe3f7c8114aa0f1d8c1978855", size = 530996, upload-time = "2026-05-19T07:45:44.229Z" }, - { url = "https://files.pythonhosted.org/packages/b4/22/14dbedb6b61f492d5524077fd10bbfb137583b0f0aafa6cd870ccb43f39a/uuid_utils-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:756575d082ea4cb7d2f923d5b640c0efe7c82573aab49220c4e09b62d13737ff", size = 169358, upload-time = "2026-05-19T07:45:05.146Z" }, - { url = "https://files.pythonhosted.org/packages/25/f4/a636806c98401a1108f2456e9cc3fa39a618145bfb1d0860c57203159cfe/uuid_utils-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:aa50261a83991dbb570a00573741455bd8f3249444f7329e5bdcd494799d1504", size = 174813, upload-time = "2026-05-19T07:45:59.579Z" }, - { url = "https://files.pythonhosted.org/packages/75/12/3823742459d87a100deb24bb6b41692aa961b267abd130fa7739cdf7d409/uuid_utils-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:22a17e93a371d850ffce8fcdbacc2239f890efe73aa3262b6170c1febc08afe1", size = 171733, upload-time = "2026-05-19T07:45:29.283Z" }, -] - -[[package]] -name = "uvicorn" -version = "0.48.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" }, -] - -[[package]] -name = "watchfiles" -version = "0.24.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c8/27/2ba23c8cc85796e2d41976439b08d52f691655fdb9401362099502d1f0cf/watchfiles-0.24.0.tar.gz", hash = "sha256:afb72325b74fa7a428c009c1b8be4b4d7c2afedafb2982827ef2156646df2fe1", size = 37870, upload-time = "2024-08-28T16:21:37.42Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/35/82/92a7bb6dc82d183e304a5f84ae5437b59ee72d48cee805a9adda2488b237/watchfiles-0.24.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7211b463695d1e995ca3feb38b69227e46dbd03947172585ecb0588f19b0d87a", size = 374137, upload-time = "2024-08-28T16:20:23.055Z" }, - { url = "https://files.pythonhosted.org/packages/87/91/49e9a497ddaf4da5e3802d51ed67ff33024597c28f652b8ab1e7c0f5718b/watchfiles-0.24.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b8693502d1967b00f2fb82fc1e744df128ba22f530e15b763c8d82baee15370", size = 367733, upload-time = "2024-08-28T16:20:24.543Z" }, - { url = "https://files.pythonhosted.org/packages/0d/d8/90eb950ab4998effea2df4cf3a705dc594f6bc501c5a353073aa990be965/watchfiles-0.24.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdab9555053399318b953a1fe1f586e945bc8d635ce9d05e617fd9fe3a4687d6", size = 437322, upload-time = "2024-08-28T16:20:25.572Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a2/300b22e7bc2a222dd91fce121cefa7b49aa0d26a627b2777e7bdfcf1110b/watchfiles-0.24.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:34e19e56d68b0dad5cff62273107cf5d9fbaf9d75c46277aa5d803b3ef8a9e9b", size = 433409, upload-time = "2024-08-28T16:20:26.628Z" }, - { url = "https://files.pythonhosted.org/packages/99/44/27d7708a43538ed6c26708bcccdde757da8b7efb93f4871d4cc39cffa1cc/watchfiles-0.24.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:41face41f036fee09eba33a5b53a73e9a43d5cb2c53dad8e61fa6c9f91b5a51e", size = 452142, upload-time = "2024-08-28T16:20:28.003Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ec/c4e04f755be003129a2c5f3520d2c47026f00da5ecb9ef1e4f9449637571/watchfiles-0.24.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5148c2f1ea043db13ce9b0c28456e18ecc8f14f41325aa624314095b6aa2e9ea", size = 469414, upload-time = "2024-08-28T16:20:29.55Z" }, - { url = "https://files.pythonhosted.org/packages/c5/4e/cdd7de3e7ac6432b0abf282ec4c1a1a2ec62dfe423cf269b86861667752d/watchfiles-0.24.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e4bd963a935aaf40b625c2499f3f4f6bbd0c3776f6d3bc7c853d04824ff1c9f", size = 472962, upload-time = "2024-08-28T16:20:31.314Z" }, - { url = "https://files.pythonhosted.org/packages/27/69/e1da9d34da7fc59db358424f5d89a56aaafe09f6961b64e36457a80a7194/watchfiles-0.24.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c79d7719d027b7a42817c5d96461a99b6a49979c143839fc37aa5748c322f234", size = 425705, upload-time = "2024-08-28T16:20:32.427Z" }, - { url = "https://files.pythonhosted.org/packages/e8/c1/24d0f7357be89be4a43e0a656259676ea3d7a074901f47022f32e2957798/watchfiles-0.24.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:32aa53a9a63b7f01ed32e316e354e81e9da0e6267435c7243bf8ae0f10b428ef", size = 612851, upload-time = "2024-08-28T16:20:33.527Z" }, - { url = "https://files.pythonhosted.org/packages/c7/af/175ba9b268dec56f821639c9893b506c69fd999fe6a2e2c51de420eb2f01/watchfiles-0.24.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ce72dba6a20e39a0c628258b5c308779b8697f7676c254a845715e2a1039b968", size = 594868, upload-time = "2024-08-28T16:20:34.639Z" }, - { url = "https://files.pythonhosted.org/packages/44/81/1f701323a9f70805bc81c74c990137123344a80ea23ab9504a99492907f8/watchfiles-0.24.0-cp312-none-win32.whl", hash = "sha256:d9018153cf57fc302a2a34cb7564870b859ed9a732d16b41a9b5cb2ebed2d444", size = 264109, upload-time = "2024-08-28T16:20:35.692Z" }, - { url = "https://files.pythonhosted.org/packages/b4/0b/32cde5bc2ebd9f351be326837c61bdeb05ad652b793f25c91cac0b48a60b/watchfiles-0.24.0-cp312-none-win_amd64.whl", hash = "sha256:551ec3ee2a3ac9cbcf48a4ec76e42c2ef938a7e905a35b42a1267fa4b1645896", size = 277055, upload-time = "2024-08-28T16:20:36.849Z" }, - { url = "https://files.pythonhosted.org/packages/4b/81/daade76ce33d21dbec7a15afd7479de8db786e5f7b7d249263b4ea174e08/watchfiles-0.24.0-cp312-none-win_arm64.whl", hash = "sha256:b52a65e4ea43c6d149c5f8ddb0bef8d4a1e779b77591a458a893eb416624a418", size = 266169, upload-time = "2024-08-28T16:20:38.149Z" }, - { url = "https://files.pythonhosted.org/packages/30/dc/6e9f5447ae14f645532468a84323a942996d74d5e817837a5c8ce9d16c69/watchfiles-0.24.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3d2e3ab79a1771c530233cadfd277fcc762656d50836c77abb2e5e72b88e3a48", size = 373764, upload-time = "2024-08-28T16:20:39.263Z" }, - { url = "https://files.pythonhosted.org/packages/79/c0/c3a9929c372816c7fc87d8149bd722608ea58dc0986d3ef7564c79ad7112/watchfiles-0.24.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:327763da824817b38ad125dcd97595f942d720d32d879f6c4ddf843e3da3fe90", size = 367873, upload-time = "2024-08-28T16:20:40.399Z" }, - { url = "https://files.pythonhosted.org/packages/2e/11/ff9a4445a7cfc1c98caf99042df38964af12eed47d496dd5d0d90417349f/watchfiles-0.24.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd82010f8ab451dabe36054a1622870166a67cf3fce894f68895db6f74bbdc94", size = 438381, upload-time = "2024-08-28T16:20:41.371Z" }, - { url = "https://files.pythonhosted.org/packages/48/a3/763ba18c98211d7bb6c0f417b2d7946d346cdc359d585cc28a17b48e964b/watchfiles-0.24.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d64ba08db72e5dfd5c33be1e1e687d5e4fcce09219e8aee893a4862034081d4e", size = 432809, upload-time = "2024-08-28T16:20:42.504Z" }, - { url = "https://files.pythonhosted.org/packages/30/4c/616c111b9d40eea2547489abaf4ffc84511e86888a166d3a4522c2ba44b5/watchfiles-0.24.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1cf1f6dd7825053f3d98f6d33f6464ebdd9ee95acd74ba2c34e183086900a827", size = 451801, upload-time = "2024-08-28T16:20:43.696Z" }, - { url = "https://files.pythonhosted.org/packages/b6/be/d7da83307863a422abbfeb12903a76e43200c90ebe5d6afd6a59d158edea/watchfiles-0.24.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:43e3e37c15a8b6fe00c1bce2473cfa8eb3484bbeecf3aefbf259227e487a03df", size = 468886, upload-time = "2024-08-28T16:20:44.847Z" }, - { url = "https://files.pythonhosted.org/packages/1d/d3/3dfe131ee59d5e90b932cf56aba5c996309d94dafe3d02d204364c23461c/watchfiles-0.24.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88bcd4d0fe1d8ff43675360a72def210ebad3f3f72cabfeac08d825d2639b4ab", size = 472973, upload-time = "2024-08-28T16:20:45.991Z" }, - { url = "https://files.pythonhosted.org/packages/42/6c/279288cc5653a289290d183b60a6d80e05f439d5bfdfaf2d113738d0f932/watchfiles-0.24.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:999928c6434372fde16c8f27143d3e97201160b48a614071261701615a2a156f", size = 425282, upload-time = "2024-08-28T16:20:47.579Z" }, - { url = "https://files.pythonhosted.org/packages/d6/d7/58afe5e85217e845edf26d8780c2d2d2ae77675eeb8d1b8b8121d799ce52/watchfiles-0.24.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:30bbd525c3262fd9f4b1865cb8d88e21161366561cd7c9e1194819e0a33ea86b", size = 612540, upload-time = "2024-08-28T16:20:48.915Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d5/b96eeb9fe3fda137200dd2f31553670cbc731b1e13164fd69b49870b76ec/watchfiles-0.24.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:edf71b01dec9f766fb285b73930f95f730bb0943500ba0566ae234b5c1618c18", size = 593625, upload-time = "2024-08-28T16:20:50.543Z" }, - { url = "https://files.pythonhosted.org/packages/c1/e5/c326fe52ee0054107267608d8cea275e80be4455b6079491dfd9da29f46f/watchfiles-0.24.0-cp313-none-win32.whl", hash = "sha256:f4c96283fca3ee09fb044f02156d9570d156698bc3734252175a38f0e8975f07", size = 263899, upload-time = "2024-08-28T16:20:51.759Z" }, - { url = "https://files.pythonhosted.org/packages/a6/8b/8a7755c5e7221bb35fe4af2dc44db9174f90ebf0344fd5e9b1e8b42d381e/watchfiles-0.24.0-cp313-none-win_amd64.whl", hash = "sha256:a974231b4fdd1bb7f62064a0565a6b107d27d21d9acb50c484d2cdba515b9366", size = 276622, upload-time = "2024-08-28T16:20:52.82Z" }, -] - -[[package]] -name = "wcwidth" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, -] - -[[package]] -name = "websocket-client" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, -] - -[[package]] -name = "websockets" -version = "16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, -] - -[[package]] -name = "widgetsnbextension" -version = "4.0.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b7404b7aefcd7569a9c0d6bd071299bf4198ae7a5d95/widgetsnbextension-4.0.15.tar.gz", hash = "sha256:de8610639996f1567952d763a5a41af8af37f2575a41f9852a38f947eb82a3b9", size = 1097402, upload-time = "2025-11-01T21:15:55.178Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, -] - -[[package]] -name = "wrapt" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/0c/bfae7b9401583b6d05938cd16dedc43857d96da2f8a3d50d78cc515bf6ff/wrapt-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ffad790d9d11d8ecf9f17c4bb671a5b4089e4d8b575c46c5129597f41f836b0", size = 81021, upload-time = "2026-05-22T14:48:00.313Z" }, - { url = "https://files.pythonhosted.org/packages/26/58/80f6a6599f933f4caecc1cb3ee88a04faf81e8b9bddbd6109c688dd63e0f/wrapt-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:628f5220c7a904d5fc78f7075c8d7871433eb6d035c94728a22fdf85f193d2a8", size = 81692, upload-time = "2026-05-22T14:48:01.49Z" }, - { url = "https://files.pythonhosted.org/packages/17/93/fb357cc7847c58a8ae790be718903afa81a28d23e642c843dc4129e8a0b2/wrapt-2.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61acce4257a9883669703c525447c5b4c392edf0f987ae77ec32668440158f0e", size = 169364, upload-time = "2026-05-22T14:48:02.791Z" }, - { url = "https://files.pythonhosted.org/packages/aa/0b/76b601ee309a8bd556af0eecb184394c20b3c49aa9c8e085aa1ffacc2568/wrapt-2.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727ab4244622cd6ad2390f322642090c877d2e83a608d2653a7643ae5368d926", size = 171079, upload-time = "2026-05-22T14:48:04.22Z" }, - { url = "https://files.pythonhosted.org/packages/cd/87/ee3f32d5658e3e26d3e0e457922b47a36dd3bfbdfee7f97bb3e802344a66/wrapt-2.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03df9ebed4c73ab93fa8c07e3d41d818dfca1852b15731a3de59457b27814624", size = 160205, upload-time = "2026-05-22T14:48:05.553Z" }, - { url = "https://files.pythonhosted.org/packages/b1/d0/ae2fd64277a67f5d7bffcf2d05eea1e476263fb2a072baf0b0129ab85984/wrapt-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9ff006f420b2ec8296aa56ade43ea7da3e997e85769f0aafc5e0661aacb710", size = 168922, upload-time = "2026-05-22T14:48:07.132Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f3/2d541a060c5bbafb9400bca4917e4d78bfd1f239f404782c86831a8f6b29/wrapt-2.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:844c858fc3bb7eacc0ba8efa904935d16aac6a4470948ad1e7e55c9f5a2a665f", size = 158388, upload-time = "2026-05-22T14:48:08.629Z" }, - { url = "https://files.pythonhosted.org/packages/1d/68/8d92c8800c57e93cb116ae9e9d6cbafc34fade5ee9f9107b6f203fb4dc35/wrapt-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87bacdaf225117a342a20d9c03438d701c02112f6e3f351ce9b7f32354f14797", size = 167682, upload-time = "2026-05-22T14:48:10.042Z" }, - { url = "https://files.pythonhosted.org/packages/30/72/83ea3790ea352439442349388e29ff07b76e0686265f9088bbb505d1608d/wrapt-2.2.1-cp312-cp312-win32.whl", hash = "sha256:2f8c90c8afde51969487be4e1343ae049b268854877d415c2510baf833775052", size = 77857, upload-time = "2026-05-22T14:48:11.782Z" }, - { url = "https://files.pythonhosted.org/packages/ef/cb/99450668dd3502d62a54a1c8aa56e44f34cb8c1261b381cfe2e7926c3b75/wrapt-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ce32763ac31ce94fe9aada947e479b1975012bff166da409b4b9e4e376cf7e5", size = 80825, upload-time = "2026-05-22T14:48:13.046Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3a/87512881be64e743f9ee4c66f4cbe8e884974bef2a5989af71f999653ac7/wrapt-2.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d1b4d0e0c2119587a31f5c029abd547e0c81d93b89d394566fe1588659eb579", size = 79087, upload-time = "2026-05-22T14:48:14.323Z" }, - { url = "https://files.pythonhosted.org/packages/88/d1/a1b08f8f4fac8cbb156fa51cf64ee2c7f7f74f9875ba3cf70b3c58368694/wrapt-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d2beb1c7cab10603aecdc42f8edd6ff013f9a32e4543474e38e6b77ce9975aeb", size = 80831, upload-time = "2026-05-22T14:48:15.598Z" }, - { url = "https://files.pythonhosted.org/packages/54/ce/57890814991446a845e09b3445ce8b694f27eb0577004f2c2a36a9772ed4/wrapt-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0cb7e4dd71f4c32e5e84843cd3c4cd65dda034314004bbe1d7f99af2426ab80", size = 81375, upload-time = "2026-05-22T14:48:17.071Z" }, - { url = "https://files.pythonhosted.org/packages/38/65/08d7a6c76ac4493bdb668205ee9c1de1bd5daca61717c3e9aa49b4c01499/wrapt-2.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95821352042722cd9f1108874579a47989d0a7e12a37d87d2fc4af20fd99ab8a", size = 167417, upload-time = "2026-05-22T14:48:18.303Z" }, - { url = "https://files.pythonhosted.org/packages/62/ce/f1ccbee7a1bfe5cdc6b3da6bab4b45713d628b9294da32a39f563d648140/wrapt-2.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd621552ede77c4c69be7fac44ba911225b0c812b6ba604e5964cf98085b474", size = 166948, upload-time = "2026-05-22T14:48:19.768Z" }, - { url = "https://files.pythonhosted.org/packages/86/2a/f85d48d1cd4869aee6704028d257d740a47c1c467b457ce396b4b5b55d07/wrapt-2.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3677c7146ce694874941ba82b57092cc4875445aadf29d72807351023105143", size = 158148, upload-time = "2026-05-22T14:48:21.96Z" }, - { url = "https://files.pythonhosted.org/packages/fe/5c/93939ad11d4a12358ab1aab219a2ef5efa5612e0db6b9fc65af8af1a891b/wrapt-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9a5934eaea872e17936b5f45501eba5ab0bce9a74122e172b663d7c28c459c4a", size = 165905, upload-time = "2026-05-22T14:48:23.373Z" }, - { url = "https://files.pythonhosted.org/packages/e0/22/b8c2aa89862ff58605934d7abf4b70e6a5a1c33df96656f49035ccdf1c8a/wrapt-2.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f5b9daf6b629fce418e0cc3dd0436eac045188fa35deadb7a7f3941d5b8203f9", size = 156712, upload-time = "2026-05-22T14:48:24.767Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/bf00a7b02239c12bb02ddcc3c0b971bfcc36e578c5a44f1ccfef5b458545/wrapt-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f53ac9f3ef573326d009ed809beff4efcac6451931c2b8132586da4b9e53ff31", size = 166560, upload-time = "2026-05-22T14:48:26.83Z" }, - { url = "https://files.pythonhosted.org/packages/fe/93/6390ca9c5b787683cef588d04f57c8d41b9a2323b5597a65f18638c90ef2/wrapt-2.2.1-cp313-cp313-win32.whl", hash = "sha256:1ffa9cfd4bdb581539951b14ae661ff20ed0c3599b3e911a131ee0ec5ac11337", size = 77817, upload-time = "2026-05-22T14:48:28.221Z" }, - { url = "https://files.pythonhosted.org/packages/97/73/ce10f0e71c0cfaa1a65faadb8efd4852028b3bb9ba28932b8889df769d38/wrapt-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:368eac1e20fd0bb03dd3cc42bf9887154c3861b60989389ccb5fac032617d215", size = 80736, upload-time = "2026-05-22T14:48:30.139Z" }, - { url = "https://files.pythonhosted.org/packages/c7/4c/89f4a6818fafbbd840330e4fa3873073e1bfc166133a64cac7f8fde7a5e3/wrapt-2.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:c754dafdf5aaf0b401b644a90a30046929a0dd1a536e0ff0ec959a59155d9c7f", size = 79099, upload-time = "2026-05-22T14:48:31.405Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f2/9a8741c46f8c208ac0a45b25ba170bcb4fb72a2781d5fb97dbd7b6be73cb/wrapt-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ed928d0fda15fc0adc8d13305c8b3c0f2fba5b0669950c9e6d019d9162a3b3e8", size = 82802, upload-time = "2026-05-22T14:48:33.307Z" }, - { url = "https://files.pythonhosted.org/packages/9c/0d/e9c855716a3705eef1416456bdf062b60620726fdc59428ff670fc3c60dc/wrapt-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fafb4e739e43544d12cb4abd1605fd4683b6ca6a9ad682b7fd8f4d21973eafa8", size = 83329, upload-time = "2026-05-22T14:48:34.593Z" }, - { url = "https://files.pythonhosted.org/packages/3b/d6/a88f1c13112b7831adac75cea65d8310e0d696d570c8961844c90a57b865/wrapt-2.2.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:74d6a0c31472fe5d814917266b9f46495d7c61ed890af08b468acea92fb89a8d", size = 202937, upload-time = "2026-05-22T14:48:35.859Z" }, - { url = "https://files.pythonhosted.org/packages/42/65/e29d54aef06a4d898a5b8a25589a0b3769bde454f922fad8f6f89fbfb650/wrapt-2.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab5be648d5a0b86b7438864f8df3c705a65cef35a2fd3e5561e3e203167e0f27", size = 209997, upload-time = "2026-05-22T14:48:38.153Z" }, - { url = "https://files.pythonhosted.org/packages/2a/91/e4454263516cf0e12640912fbca9a83654e424f0a6ddb79f5cd7ce14bf33/wrapt-2.2.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d8f204c8e3a8bf9ece17e0a83d137fd807440977f8a5e762d59306795011440", size = 194856, upload-time = "2026-05-22T14:48:39.69Z" }, - { url = "https://files.pythonhosted.org/packages/de/d0/fe0ee202286afdf4a7f77dd29f195703145764d572aec209c5086e57d924/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d047f6498c973874ba08ac3f97c69a2c4b2211c8de6f4c205f75cb1c9522596e", size = 205654, upload-time = "2026-05-22T14:48:43.456Z" }, - { url = "https://files.pythonhosted.org/packages/23/b6/87d860dfc6460c246af70b1fd5c8b76df77571b42a493459423ded94fd7d/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:7a4fdb9326aab4a5a477a1640e5ad786a8495901009d7e7b038371edd23a9d2b", size = 192206, upload-time = "2026-05-22T14:48:44.858Z" }, - { url = "https://files.pythonhosted.org/packages/df/46/3eea8cde077d985f239a38c0257087b8064fd9ee9b1a99e282d2c86da4ef/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8cc5094b08abeae52da9c73c8a32003623be691a5193df2f4e3eac3d557c394", size = 198428, upload-time = "2026-05-22T14:48:46.319Z" }, - { url = "https://files.pythonhosted.org/packages/18/dc/b927ee9c7fc67adc3a5658f246a0d275425eb840ba36e7b702e70f18bde8/wrapt-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562", size = 79448, upload-time = "2026-05-22T14:48:47.901Z" }, - { url = "https://files.pythonhosted.org/packages/ec/b3/fd30b473fe498c70e6b9a5f328b8d3fbaf1b8c3c481465f59724bba8eb70/wrapt-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53", size = 83021, upload-time = "2026-05-22T14:48:49.201Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f3/96c39153a8737a6e9aa85adef254ac4195bea3f2d24efc60472ccc3c9e2e/wrapt-2.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e", size = 80295, upload-time = "2026-05-22T14:48:50.479Z" }, - { url = "https://files.pythonhosted.org/packages/0a/a3/11d7f34ebbf3231bc907a3e6d5ee051b14d034c1bc7b65a97d5cc00516df/wrapt-2.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab", size = 80879, upload-time = "2026-05-22T14:48:51.802Z" }, - { url = "https://files.pythonhosted.org/packages/13/3c/b74cfd984cef560b900fb1a727af20352d89e1f06bf2e1114dd3f00f5f5a/wrapt-2.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c", size = 81462, upload-time = "2026-05-22T14:48:53.18Z" }, - { url = "https://files.pythonhosted.org/packages/15/a3/7c8f704b8dc07dfe0a5d01c2edbfd88317aa8e5e3fa7c743eb7a085ae767/wrapt-2.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c", size = 167251, upload-time = "2026-05-22T14:48:54.562Z" }, - { url = "https://files.pythonhosted.org/packages/80/85/a34d1888d97247da6c2ff6118c3a721c73ed8cc4dd198c00208bb73b6f80/wrapt-2.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e", size = 166316, upload-time = "2026-05-22T14:48:56.065Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d7/72ffaeb01eebc704afe3fb99e840480f4bda45f0fa66e3381b6a39251c8f/wrapt-2.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f", size = 157952, upload-time = "2026-05-22T14:48:57.924Z" }, - { url = "https://files.pythonhosted.org/packages/24/5b/36f5d6b024e4edfdd90b140742d11ebcf7836daf5c9daf326c55c24db412/wrapt-2.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508", size = 166130, upload-time = "2026-05-22T14:48:59.384Z" }, - { url = "https://files.pythonhosted.org/packages/81/06/9296d9e97bfdef5483dfcc859d57b095b257144b2bc5300ab521e06f4bc7/wrapt-2.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5", size = 156604, upload-time = "2026-05-22T14:49:00.921Z" }, - { url = "https://files.pythonhosted.org/packages/53/37/16953929ed6776175720e58fc966e779926d8d71e2c7b2273230590ca71f/wrapt-2.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283", size = 166007, upload-time = "2026-05-22T14:49:02.332Z" }, - { url = "https://files.pythonhosted.org/packages/b9/73/20ee58c0612dae7c31131a7095345812ed2c7b389019e175f68cde34e5b4/wrapt-2.2.1-cp314-cp314-win32.whl", hash = "sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243", size = 78327, upload-time = "2026-05-22T14:49:03.722Z" }, - { url = "https://files.pythonhosted.org/packages/22/b3/ef7c3295d02e0448a71c639a36a057f46d524d057c9486291a7a3039e65c/wrapt-2.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b", size = 81144, upload-time = "2026-05-22T14:49:05.093Z" }, - { url = "https://files.pythonhosted.org/packages/ac/dc/7bdf336953f99f4ceb0a584bb8870e42c8f26f93ea10c87834dad62f1668/wrapt-2.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36", size = 79569, upload-time = "2026-05-22T14:49:06.413Z" }, - { url = "https://files.pythonhosted.org/packages/6a/6d/6dfae80150ff1919c356d1dd528f049bcdfaae29b4d284bc957e022caef4/wrapt-2.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188", size = 82892, upload-time = "2026-05-22T14:49:07.925Z" }, - { url = "https://files.pythonhosted.org/packages/82/7b/4e34766a7d7804ffce9e71befe47e9b3225dc350c49c94493c4ab39fd3a5/wrapt-2.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199", size = 83333, upload-time = "2026-05-22T14:49:09.257Z" }, - { url = "https://files.pythonhosted.org/packages/9d/57/0b34db3e8de44ccfece62d7b337abd1631dd810f5adc5f3db571727836b5/wrapt-2.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413", size = 202899, upload-time = "2026-05-22T14:49:10.572Z" }, - { url = "https://files.pythonhosted.org/packages/e5/45/ac0c459f154b99d92789a6cba7ca727185b83513b986f8ec7fe2aacddcbf/wrapt-2.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956", size = 209986, upload-time = "2026-05-22T14:49:12.229Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e4/77e37ff33ad018fa81ade52c25fa327b80b56f81d734279a63614fcb4cbc/wrapt-2.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e", size = 194893, upload-time = "2026-05-22T14:49:14.139Z" }, - { url = "https://files.pythonhosted.org/packages/dd/9d/7ea651d1ab032fc5fa222fbec91d0f8a1397f6ae04ebb93fa7219aa921d7/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85", size = 205636, upload-time = "2026-05-22T14:49:15.714Z" }, - { url = "https://files.pythonhosted.org/packages/09/af/8e88031a701275b9085c54e64bc88c0b1cd55c77eadd400691c371cd76c4/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181", size = 192267, upload-time = "2026-05-22T14:49:17.283Z" }, - { url = "https://files.pythonhosted.org/packages/bf/a8/e657ca876b06710194f243d81c4b0896ade646e244bdbec2d87c8c56a8bd/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a", size = 198378, upload-time = "2026-05-22T14:49:18.785Z" }, - { url = "https://files.pythonhosted.org/packages/c8/59/822efe4ea722a3961331bfa35b7d90937790d2c20f0616de1997ccc3aebd/wrapt-2.2.1-cp314-cp314t-win32.whl", hash = "sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85", size = 80226, upload-time = "2026-05-22T14:49:20.264Z" }, - { url = "https://files.pythonhosted.org/packages/ab/31/2a7dc5f6abb2fca0b6e1610e120419f603650aceb4f1d3ac4cae0354e162/wrapt-2.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50", size = 83835, upload-time = "2026-05-22T14:49:21.634Z" }, - { url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722, upload-time = "2026-05-22T14:49:23.59Z" }, - { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, -] - -[[package]] -name = "xxhash" -version = "3.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/2f/e183a1b407002f5af81822bee18b61cdb94b8670208ef34734d8d2b8ebe9/xxhash-3.7.0.tar.gz", hash = "sha256:6cc4eefbb542a5d6ffd6d70ea9c502957c925e800f998c5630ecc809d6702bae", size = 82022, upload-time = "2026-04-25T11:10:32.553Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/8a/51a14cdef4728c6c2337db8a7d8704422cc65676d9199d77215464c880af/xxhash-3.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:082c87bfdd2b9f457606c7a4a53457f4c4b48b0cdc48de0277f4349d79bb3d7a", size = 33357, upload-time = "2026-04-25T11:06:20.44Z" }, - { url = "https://files.pythonhosted.org/packages/b9/1b/0c2c933809421ffd9bf42b59315552c143c755db5d9a816b2f1ae273e884/xxhash-3.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5e7ce913b61f35b0c1c839a49ac9c8e75dd8d860150688aed353b0ce1bf409d8", size = 30869, upload-time = "2026-04-25T11:06:21.989Z" }, - { url = "https://files.pythonhosted.org/packages/03/a8/89d5fdd6ee12d70ba99451de46dd0e8010167468dcd913ec855653f4dd50/xxhash-3.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3beb1de3b1e9694fcdd853e570ee64c631c7062435d2f8c69c1adf809bc086f0", size = 194100, upload-time = "2026-04-25T11:06:23.586Z" }, - { url = "https://files.pythonhosted.org/packages/87/ee/2f9f2ed993e77206d1e66991290a1ebe22e843351ca3ebec8e49e01ba186/xxhash-3.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3e7b689c3bce16699efcf736066f5c6cc4472c3840fe4b22bd8279daf4abdac", size = 212977, upload-time = "2026-04-25T11:06:25.019Z" }, - { url = "https://files.pythonhosted.org/packages/de/60/5a91644615a9e9d4e42c2e9925f1908e3a24e4e691d9de7340d565bea024/xxhash-3.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a6545e6b409e3d5cbafc850fb84c55a1ca26ed15a6b11e3bf07a0e0cd84517c8", size = 236373, upload-time = "2026-04-25T11:06:26.482Z" }, - { url = "https://files.pythonhosted.org/packages/22/c0/f3a9384eaaed9d14d4d062a5d953aa0da489bfe9747877aa994caa87cd0b/xxhash-3.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:31ab1461c77a11461d703c88eb949e132a1c6515933cf675d97ec680f4bd18de", size = 212229, upload-time = "2026-04-25T11:06:28.065Z" }, - { url = "https://files.pythonhosted.org/packages/2e/67/02f07a9fd79726804190f2172c4894c3ed9a4ebccaca05653c84beb58025/xxhash-3.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c4d596b7676f811172687ec567cbafb9e4dea2f9be1bbb4f622410cb7f40f40", size = 445462, upload-time = "2026-04-25T11:06:30.048Z" }, - { url = "https://files.pythonhosted.org/packages/40/37/558f5a90c0672fc9b4402dc25d87ac5b7406616e8969430c9ca4e52ee74d/xxhash-3.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13805f0461cba0a857924e70ff91ae6d52d2598f79a884e788db80532614a4a1", size = 193932, upload-time = "2026-04-25T11:06:31.857Z" }, - { url = "https://files.pythonhosted.org/packages/d5/90/aaa09cd58661d32044dbbad7df55bbe22a623032b810e7ed3b8c569a2a6f/xxhash-3.7.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d398f372496152f1c6933a33566373f8d1b37b98b8c9d608fa6edc0976f23b2", size = 284807, upload-time = "2026-04-25T11:06:33.697Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f3/53df3719ab127a02c174f0c1c74924fcd110866e89c966bc7909cfa8fa84/xxhash-3.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d610aa62cdb7d4d497740741772a24a794903bf3e79eaa51d2e800082abe11e5", size = 210445, upload-time = "2026-04-25T11:06:35.488Z" }, - { url = "https://files.pythonhosted.org/packages/72/33/d219975c0e8b6fa2eb9ccd486fe47e21bf1847985b878dd2fbc3126e0d5c/xxhash-3.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:073c23900a9fbf3d26616c17c830db28af9803677cd5b33aea3224d824111514", size = 241273, upload-time = "2026-04-25T11:06:37.24Z" }, - { url = "https://files.pythonhosted.org/packages/3e/50/49b1afe610eb3964cedcb90a4d4c3d46a261ee8669cbd4f060652619ae3c/xxhash-3.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:418a463c3e6a590c0cdc890f8be19adb44a8c8acd175ca5b2a6de77e61d0b386", size = 197950, upload-time = "2026-04-25T11:06:39.148Z" }, - { url = "https://files.pythonhosted.org/packages/c6/75/5f42a1a4c78717d906a4b6a140c6dbf837ab1f547a54d23c4e2903310936/xxhash-3.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:03f8ff4474ee61c845758ce00711d7087a770d77efb36f7e74a6e867301000b8", size = 210709, upload-time = "2026-04-25T11:06:40.958Z" }, - { url = "https://files.pythonhosted.org/packages/8a/85/237e446c25abced71e9c53d269f2cef5bab8a82b3f88a12e00c5368e7368/xxhash-3.7.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:44fba4a5f1d179b7ddc7b3dc40f56f9209046421679b57025d4d8821b376fd8d", size = 275345, upload-time = "2026-04-25T11:06:42.525Z" }, - { url = "https://files.pythonhosted.org/packages/62/34/c2c26c0a6a9cc739bc2a5f0ae03ba8b87deb12b8bce35f7ac495e790dc6d/xxhash-3.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31e3516a0f829d06ded4a2c0f3c7c5561993256bfa1c493975fb9dc7bfa828a1", size = 414056, upload-time = "2026-04-25T11:06:44.343Z" }, - { url = "https://files.pythonhosted.org/packages/a0/aa/5c58e9bc8071b8afd8dcf297ff362f723c4892168faba149f19904132bf4/xxhash-3.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b59ee2ac81de57771a09ecad09191e840a1d2fae1ef684208320591055768f83", size = 191485, upload-time = "2026-04-25T11:06:46.262Z" }, - { url = "https://files.pythonhosted.org/packages/d4/69/a929cf9d1e2e65a48b818cdce72cb6b69eab2e6877f21436d0a1942aff43/xxhash-3.7.0-cp312-cp312-win32.whl", hash = "sha256:74bbd92f8c7fcc397ba0a11bfdc106bc72ad7f11e3a60277753f87e7532b4d81", size = 30671, upload-time = "2026-04-25T11:06:48.039Z" }, - { url = "https://files.pythonhosted.org/packages/b9/1b/104b41a8947f4e1d4a66ce1e628eea752f37d1890bfd7453559ca7a3d950/xxhash-3.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:7bd7bc82dd4f185f28f35193c2e968ef46131628e3cac62f639dadf321cba4d1", size = 31514, upload-time = "2026-04-25T11:06:49.279Z" }, - { url = "https://files.pythonhosted.org/packages/98/a0/1fd0ea1f1b886d9e7c73f0397571e22333a7d79e31da6d7127c2a4a71d75/xxhash-3.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:7d7148180ec99ba36585b42c8c5de25e9b40191613bc4be68909b4d25a77a852", size = 27761, upload-time = "2026-04-25T11:06:50.448Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ca/d5174b4c36d10f64d4ca7050563138c5a599efb01a765858ddefc9c1202a/xxhash-3.7.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:4b6d6b33f141158692bd4eafbb96edbc5aa0dabdb593a962db01a91983d4f8fa", size = 36813, upload-time = "2026-04-25T11:06:51.73Z" }, - { url = "https://files.pythonhosted.org/packages/41/d0/abc6c9d347ba1f1e1e1d98125d0881a0452c7f9a76a9dd03a7b5d2197f23/xxhash-3.7.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:845d347df254d6c619f616afa921331bada8614b8d373d58725c663ba97c3605", size = 35121, upload-time = "2026-04-25T11:06:53.048Z" }, - { url = "https://files.pythonhosted.org/packages/bf/11/4cc834eb3d79f2f2b3a6ef7324195208bcdfbdcf7534d2b17267aa5f3a8f/xxhash-3.7.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:fddbbb69a6fff4f421e7a0d1fa28f894b20112e9e3fab306af451e2dfd0e459b", size = 29624, upload-time = "2026-04-25T11:06:54.311Z" }, - { url = "https://files.pythonhosted.org/packages/23/83/e97d3e7b635fe73a1dfb1e91f805324dd6d930bb42041cbf18f183bc0b6d/xxhash-3.7.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:54876a4e45101cec2bf8f31a973cda073a23e2e108538dad224ba07f85f22487", size = 30638, upload-time = "2026-04-25T11:06:55.864Z" }, - { url = "https://files.pythonhosted.org/packages/f4/40/d84951d80c35db1f4c40a29a64a8520eea5d56e764c603906b4fe763580f/xxhash-3.7.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:0c72fe9c7e3d6dfd7f1e21e224a877917fa09c465694ba4e06464b9511b65544", size = 33323, upload-time = "2026-04-25T11:06:57.336Z" }, - { url = "https://files.pythonhosted.org/packages/89/cc/c7dc6558d97e9ab023f663d69ab28b340ed9bf4d2d94f2c259cf896bb354/xxhash-3.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a6d73a830b17ef49bc04e00182bd839164c1b3c59c127cd7c54fcb10c7ed8ee8", size = 33362, upload-time = "2026-04-25T11:06:58.656Z" }, - { url = "https://files.pythonhosted.org/packages/2a/6e/46b84017b1301d54091430353d4ad5901654a3e0871649877a416f7f1644/xxhash-3.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91c3b07cf3362086d8f126c6aecd8e5e9396ad8b2f2219ea7e49a8250c318acd", size = 30874, upload-time = "2026-04-25T11:06:59.834Z" }, - { url = "https://files.pythonhosted.org/packages/df/5e/8f9158e3ab906ad3fec51e09b5ea0093e769f12207bfa42a368ca204e7ab/xxhash-3.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50e879ebbac351c81565ca108db766d7832f5b8b6a5b14b8c0151f7190028e3d", size = 194185, upload-time = "2026-04-25T11:07:01.658Z" }, - { url = "https://files.pythonhosted.org/packages/f3/29/a804ded9f5d3d3758292678d23e7528b08fda7b7e750688d08b052322475/xxhash-3.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:921c14e93817842dd0dd9f372890a0f0c72e534650b6ab13c5be5cd0db11d47e", size = 213033, upload-time = "2026-04-25T11:07:03.606Z" }, - { url = "https://files.pythonhosted.org/packages/8b/91/1ce5a7d2fdc975267320e2c78fc1cecfe7ab735ccbcf6993ec5dd541cb2c/xxhash-3.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e64a7c9d7dfca3e0fafcbc5e455519090706a3e36e95d655cec3e04e79f95aaa", size = 236140, upload-time = "2026-04-25T11:07:05.396Z" }, - { url = "https://files.pythonhosted.org/packages/34/04/fd595a4fd8617b05fa27bd9b684ecb4985bfed27917848eea85d54036d06/xxhash-3.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2220af08163baf5fa36c2b8af079dc2cbe6e66ae061385267f9472362dfd53c6", size = 212291, upload-time = "2026-04-25T11:07:06.966Z" }, - { url = "https://files.pythonhosted.org/packages/03/fb/f1a379cbc372ae5b9f4ab36154c48a849ca6ebe3ac477067a57865bf3bc6/xxhash-3.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f14bb8b22a4a91325813e3d553b8963c10cf8c756cff65ee50c194431296c655", size = 445532, upload-time = "2026-04-25T11:07:08.525Z" }, - { url = "https://files.pythonhosted.org/packages/65/59/172424b79f8cfd4b6d8a122b2193e6b8ad4b11f7159bb3b6f9b3191329bb/xxhash-3.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:496736f86a9bedaf64b0dc70e3539d0766df01c71ea22032698e88f3f04a1ce9", size = 193990, upload-time = "2026-04-25T11:07:10.315Z" }, - { url = "https://files.pythonhosted.org/packages/b9/19/aeac22161d953f139f07ba5586cb4a17c5b7b6dff985122803bb12933500/xxhash-3.7.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0ff71596bd79816975b3de7130ab1ff4541410285a3c084584eeb1c8239996fd", size = 284876, upload-time = "2026-04-25T11:07:12.15Z" }, - { url = "https://files.pythonhosted.org/packages/77/d5/4fd0b59e7a02242953da05ff679fbb961b0a4368eac97a217e11dae110c1/xxhash-3.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1ad86695c19b1d46fe106925db3c7a37f16be37669dcf58dcc70a9dd6e324676", size = 210495, upload-time = "2026-04-25T11:07:13.952Z" }, - { url = "https://files.pythonhosted.org/packages/aa/fb/976a3165c728c7faf74aa1b5ab3cf6a85e6d731612894741840524c7d28c/xxhash-3.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:970f9f8c50961d639cbd0d988c96f80ddf66006de93641719282c4fe7a87c5e6", size = 241331, upload-time = "2026-04-25T11:07:15.557Z" }, - { url = "https://files.pythonhosted.org/packages/4a/2c/6763d5901d53ac9e6ba296e5717ae599025c9d268396e8faa8b4b0a8e0ac/xxhash-3.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5886ad85e9e347911783760a1d16cb6b393e8f9e3b52c982568226cb56927bdc", size = 198037, upload-time = "2026-04-25T11:07:17.563Z" }, - { url = "https://files.pythonhosted.org/packages/61/2b/876e722d533833f5f9a83473e6ba993e48745701096944e77bbecf29b2c3/xxhash-3.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6e934bbae1e0ec74e27d5f0d7f37ef547ce5ff9f0a7e63fb39e559fc99526734", size = 210744, upload-time = "2026-04-25T11:07:19.055Z" }, - { url = "https://files.pythonhosted.org/packages/21/e6/d7e7baef7ce24166b4668d3c48557bb35a23b92ecadcac7e7718d099ab69/xxhash-3.7.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:3b6b3d28228af044ebcded71c4a3dd86e1dbd7e2f4645bf40f7b5da65bb5fb5a", size = 275406, upload-time = "2026-04-25T11:07:20.908Z" }, - { url = "https://files.pythonhosted.org/packages/92/fe/198b3763b2e01ca908f2154969a2352ec99bda892b574a11a9a151c5ede4/xxhash-3.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:6be4d70d9ab76c9f324ead9c01af6ff52c324745ea0c3731682a0cf99720f1fe", size = 414125, upload-time = "2026-04-25T11:07:23.037Z" }, - { url = "https://files.pythonhosted.org/packages/3a/6d/019a11affd5a5499137cacca53808659964785439855b5aa40dfd3412916/xxhash-3.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:151d7520838d4465461a0b7f4ae488b3b00de16183dd3214c1a6b14bf89d7fb6", size = 191555, upload-time = "2026-04-25T11:07:24.991Z" }, - { url = "https://files.pythonhosted.org/packages/76/21/b96d58568df2d01533244c3e0e5cbdd0c8b2b25c4bec4d72f19259a292d7/xxhash-3.7.0-cp313-cp313-win32.whl", hash = "sha256:d798c1e291bffb8e37b5bbe0dda77fc767cd19e89cadaf66e6ed5d0ff88c9fe6", size = 30668, upload-time = "2026-04-25T11:07:26.665Z" }, - { url = "https://files.pythonhosted.org/packages/99/57/d849a8d3afa1f8f4bc6a831cd89f49f9706fbbad94d2975d6140a171988c/xxhash-3.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:875811ba23c543b1a1c3143c926e43996eb27ebb8f52d3500744aa608c275aed", size = 31524, upload-time = "2026-04-25T11:07:27.92Z" }, - { url = "https://files.pythonhosted.org/packages/81/52/bacc753e92dee78b058af8dcef0a50815f5f860986c664a92d75f965b6a5/xxhash-3.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:54a675cb300dda83d71daae2a599389d22db8021a0f8db0dd659e14626eb3ecc", size = 27768, upload-time = "2026-04-25T11:07:29.113Z" }, - { url = "https://files.pythonhosted.org/packages/1c/47/ddbd683b7fc7e592c1a8d9d65f73ce9ab513f082b3967eee2baf549b8fc6/xxhash-3.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a3b19a42111c4057c1547a4a1396a53961dca576a0f6b82bfa88a2d1561764b2", size = 33576, upload-time = "2026-04-25T11:07:30.469Z" }, - { url = "https://files.pythonhosted.org/packages/07/f2/36d3310161db7f72efb4562aadde0ed429f1d0531782dd6345b12d2da527/xxhash-3.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8f4608a06e4d61b7a3425665a46d00e0579122e1a2fae97a0c52953a3aad9aa3", size = 31123, upload-time = "2026-04-25T11:07:31.989Z" }, - { url = "https://files.pythonhosted.org/packages/0d/3f/75937a5c69556ed213021e43cbedd84c8e0279d0d74e7d41a255d84ba4b1/xxhash-3.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ad37c7792479e49cf96c1ab25517d7003fe0d93687a772ba19a097d235bbe41e", size = 196491, upload-time = "2026-04-25T11:07:33.358Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/f10d7ff8c7a733d4403a43b9de18c8fabc005f98cec054644f04418659ee/xxhash-3.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc026e3b89d98e30a8288c95cb696e77d150b3f0fb7a51f73dcd49ee6b5577fa", size = 215793, upload-time = "2026-04-25T11:07:34.919Z" }, - { url = "https://files.pythonhosted.org/packages/8b/fd/778f60aa295f58907938f030a8b514611f391405614a525cccd2ffc00eb5/xxhash-3.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9b31ab1f28b078a6a1ac1a54eb35e7d5390deddd56870d0be3a0a733d1c321c", size = 237993, upload-time = "2026-04-25T11:07:36.638Z" }, - { url = "https://files.pythonhosted.org/packages/70/f5/736db5de387b4a540e37a05b84b40dc58a1ce974bfd2b4e5754ce29b68c3/xxhash-3.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3bb5fd680c038fd5229e44e9c493782f90df9bef632fd0499d442374688ff70b", size = 214887, upload-time = "2026-04-25T11:07:38.564Z" }, - { url = "https://files.pythonhosted.org/packages/4d/aa/09a095f22fdb9a27fbb716841fbff52119721f9ca4261952d07a912f7839/xxhash-3.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:030c0fd688fce3569fbb49a2feefd4110cbb0b650186fb4610759ecfac677548", size = 448407, upload-time = "2026-04-25T11:07:40.552Z" }, - { url = "https://files.pythonhosted.org/packages/74/8a/b745efeeca9e34a91c26fdc97ad8514c43d5a81ac78565cba80a1353870a/xxhash-3.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b1bde10324f4c31812ae0d0502e92d916ae8917cad7209353f122b8b8f610c3", size = 196119, upload-time = "2026-04-25T11:07:42.101Z" }, - { url = "https://files.pythonhosted.org/packages/8a/5c/0cfceb024af90c191f665c7933b1f318ee234f4797858383bebd1881d52f/xxhash-3.7.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:503722d52a615f2604f5e7611de7d43878df010dc0053094ef91cb9a9ac3d987", size = 286751, upload-time = "2026-04-25T11:07:43.568Z" }, - { url = "https://files.pythonhosted.org/packages/0b/0a/0793e405dc3cf8f4ebe2c1acec1e4e4608cd9e7e50ea691dabbc2a95ccbb/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c72500a3b6d6c30ebfc135035bcace9eb5884f2dc220804efcaaba43e9f611dd", size = 212961, upload-time = "2026-04-25T11:07:45.388Z" }, - { url = "https://files.pythonhosted.org/packages/0c/7e/721118ffc63bfff94aa565bcf2555a820f9f4bdb0f001e0d609bdfad70de/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:43475925a766d01ca8cd9a857fd87f3d50406983c8506a4c07c4df12adcc867f", size = 243703, upload-time = "2026-04-25T11:07:47.053Z" }, - { url = "https://files.pythonhosted.org/packages/6e/18/16f6267160488b8276fd3d449d425712512add292ba545c1b6946bfdb7dd/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8d09dfd2ab135b985daf868b594315ebe11ad86cd9fea46e6c69f19b28f7d25a", size = 200894, upload-time = "2026-04-25T11:07:48.657Z" }, - { url = "https://files.pythonhosted.org/packages/2d/94/80ba841287fd97e3e9cac1d228788c8ef623746f570404961eec748ecb5c/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c50269d0055ac1faecfd559886d2cbe4b730de236585aba0e873f9d9dadbe585", size = 213357, upload-time = "2026-04-25T11:07:50.257Z" }, - { url = "https://files.pythonhosted.org/packages/a1/7e/106d4067130c59f1e18a55ffadcd876d8c68534883a1e02685b29d3d8153/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1910df4756a5ab58cfad8744fc2d0f23926e3efcc346ee76e87b974abab922f4", size = 277600, upload-time = "2026-04-25T11:07:51.745Z" }, - { url = "https://files.pythonhosted.org/packages/c5/86/a081dd30da71d720b2612a792bfd55e45fa9a07ac76a0507f60487473c25/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d006faf3b491957efcb433489be3c149efe4787b7063d5cddb8ddaefdc60e0c1", size = 416980, upload-time = "2026-04-25T11:07:53.504Z" }, - { url = "https://files.pythonhosted.org/packages/35/29/1a95221a029a3c1293773869e1ab47b07cbbdd82444a42809e8c60156626/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:abb65b4e947e958f7b3b0d71db3ce447d1bc5f37f5eab871ce7223bda8768a04", size = 193840, upload-time = "2026-04-25T11:07:55.103Z" }, - { url = "https://files.pythonhosted.org/packages/c5/e0/db909dd0823285de2286f67e10ee4d81e96ad35d7d8e964ecb07fccd8af9/xxhash-3.7.0-cp313-cp313t-win32.whl", hash = "sha256:178959906cb1716a1ce08e0d69c82886c70a15a6f2790fc084fdd146ca30cd49", size = 30966, upload-time = "2026-04-25T11:07:56.524Z" }, - { url = "https://files.pythonhosted.org/packages/7b/ff/d705b15b22f21ee106adce239cb65d35067a158c630b240270f09b17c2e6/xxhash-3.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2524a1e20d4c231d13b50f7cf39e44265b055669a64a7a4b9a2a44faa03f19b6", size = 31784, upload-time = "2026-04-25T11:07:57.758Z" }, - { url = "https://files.pythonhosted.org/packages/a2/1f/b2cf83c3638fd0588e0b17f22e5a9400bdfb1a3e3755324ac0aee2250b88/xxhash-3.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:37d994d0ffe81ef087bb330d392caa809bb5853c77e22ea3f71db024a0543dba", size = 27932, upload-time = "2026-04-25T11:07:59.109Z" }, - { url = "https://files.pythonhosted.org/packages/0e/cc/431db584f6fbb9312e40a173af027644e5580d39df1f73603cbb9dca4d6b/xxhash-3.7.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:8c5fcfd806c335bfa2adf1cd0b3110a44fc7b6995c3a648c27489bae85801465", size = 36644, upload-time = "2026-04-25T11:08:00.658Z" }, - { url = "https://files.pythonhosted.org/packages/bc/01/255ec513e0a705d1f9a61413e78dfce4e3235203f0ed525a24c2b4b56345/xxhash-3.7.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:506a0b488f190f0a06769575e30caf71615c898ed93ab18b0dbcb6dec5c3713c", size = 35003, upload-time = "2026-04-25T11:08:02.338Z" }, - { url = "https://files.pythonhosted.org/packages/68/70/c55fc33c93445b44d8fc5a17b41ed99e3cebe92bcf8396809e63fc9a1165/xxhash-3.7.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:ec68dbba21532c0173a9872298e65c89749f7c9d21538c3a78b5bb6105871568", size = 29655, upload-time = "2026-04-25T11:08:03.701Z" }, - { url = "https://files.pythonhosted.org/packages/c2/72/ff8de73df000d74467d12a59ce6d6e2b2a368b978d41ab7b1fba5ed442be/xxhash-3.7.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:fa77e7ec1450d415d20129961814787c9abd9a07f98872f070b1fe96c5084611", size = 30664, upload-time = "2026-04-25T11:08:05.011Z" }, - { url = "https://files.pythonhosted.org/packages/b6/91/08416d9bd9bc3bf39d831abe8a5631ac2db5141dfd6fe81c3fe59a1f9264/xxhash-3.7.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:fe32736295ea38e43e7d9424053c8c47c9f64fecfc7c895fb3da9b30b131c9ee", size = 33317, upload-time = "2026-04-25T11:08:06.413Z" }, - { url = "https://files.pythonhosted.org/packages/0e/3b/86b1caa4dee10a99f4bf9521e623359341c5e50d05158fa10c275b2bd079/xxhash-3.7.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ab9dd2c83c4bbd63e422181a76f13502d049d3ddcac9a1bdc29196263d692bb8", size = 33457, upload-time = "2026-04-25T11:08:08.099Z" }, - { url = "https://files.pythonhosted.org/packages/ed/38/98ea14ad1517e1461292a65906951458d520689782bfbae111050145bdba/xxhash-3.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3afec3a336a2286601a437cb07562ab0227685e6fbb9ec17e8c18457ff348ecf", size = 30894, upload-time = "2026-04-25T11:08:09.429Z" }, - { url = "https://files.pythonhosted.org/packages/61/a2/074654d0b893606541199993c7db70067d9fc63b748e0d60020a52a1bd36/xxhash-3.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:565df64437a9390f84465dcca33e7377114c7ede8d05cd2cf20081f831ea788e", size = 194409, upload-time = "2026-04-25T11:08:10.91Z" }, - { url = "https://files.pythonhosted.org/packages/e2/26/6d2a1afc468189f77ca28c32e1c83e1b9da1178231e05641dbc1b350e332/xxhash-3.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12eca820a5d558633d423bf8bb78ce72a55394823f64089247f788a7e0ae691e", size = 213135, upload-time = "2026-04-25T11:08:12.575Z" }, - { url = "https://files.pythonhosted.org/packages/8e/0e/d8aecf95e09c42547453137be74d2f7b8b14e08f5177fa2fab6144a19061/xxhash-3.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f262b8f7599516567e070abf607b9af649052b2c4bd6f9be02b0cb41b7024805", size = 236379, upload-time = "2026-04-25T11:08:14.206Z" }, - { url = "https://files.pythonhosted.org/packages/f2/74/8140e8210536b3dd0cc816c4faaeb5ba6e63e8125ab25af4bcddd6a037b3/xxhash-3.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1598916cb197681e03e601901e4ab96a9a963de398c59d0964f8a6f44a2b361", size = 212447, upload-time = "2026-04-25T11:08:15.79Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d2/462001d2903b4bee5a5689598a0a55e5e7cd1ac7f4247a5545cff10d3ebb/xxhash-3.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:322b2f0622230f526aeb1738149948a7ae357a9e2ceb1383c6fd1fdaecdafa16", size = 445660, upload-time = "2026-04-25T11:08:17.441Z" }, - { url = "https://files.pythonhosted.org/packages/23/09/2bd1ed7f8689b20e51727952cac8329d50c694dc32b2eba06ba5bc742b37/xxhash-3.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24cc22070880cc57b830a65cde4e65fa884c6d9b28ae4803b5ee05911e7bafba", size = 194076, upload-time = "2026-04-25T11:08:19.134Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6e/692302cd0a5f4ac4e6289f37fa888dc2e1e07750b68fe3e4bfe939b8cea3/xxhash-3.7.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb5a888a968b2434abf9ecda357b5d43f10d7b5a6da6fdbbe036208473aff0e2", size = 284990, upload-time = "2026-04-25T11:08:20.618Z" }, - { url = "https://files.pythonhosted.org/packages/05/d9/e54b159b3d9df7999d2a7c676ce7b323d1b5588a64f8f51ed8172567bd87/xxhash-3.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a999771ff97bec27d18341be4f3a36b163bb1ac41ec17bef6d2dabd84acd33c7", size = 210590, upload-time = "2026-04-25T11:08:22.24Z" }, - { url = "https://files.pythonhosted.org/packages/50/93/0e0df1a3a196ced4ca71de76d65ead25d8e87bbfb87b64306ea47a40c00d/xxhash-3.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ed4a6efe2dee1655adb73e7ad40c6aa955a6892422b1e3b95de6a34de56e3cbb", size = 241442, upload-time = "2026-04-25T11:08:23.844Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a9/d917a7a814e90b218f8a0d37967105eea91bf752c3303683c99a1f7bfc1f/xxhash-3.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9fd17f14ac0faa12126c2f9ca774a8cf342957265ec3c8669c144e5e6cdb478c", size = 198356, upload-time = "2026-04-25T11:08:25.99Z" }, - { url = "https://files.pythonhosted.org/packages/89/5e/f2ba1877c39469abbefc72991d6ebdcbd4c0880db01ae8cb1f553b0c537d/xxhash-3.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:05fd1254268c59b5cb2a029dfc204275e9fc52de2913f1e53aa8d01442c96b4d", size = 210898, upload-time = "2026-04-25T11:08:27.608Z" }, - { url = "https://files.pythonhosted.org/packages/90/c6/be56b58e73de531f39a10de1355bb77ceb663900dc4bf2d6d3002a9c3f9e/xxhash-3.7.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a2eae53197c6276d5b317f75a1be226bbf440c20b58bf525f36b5d0e1f657ca6", size = 275519, upload-time = "2026-04-25T11:08:29.301Z" }, - { url = "https://files.pythonhosted.org/packages/92/e2/17ddc85d5765b9c709f192009ed8f5a1fc876f4eb35bba7c307b5b1169f9/xxhash-3.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bfe6f92e3522dcbe8c4281efd74fa7542a336cb00b0e3272c4ec0edabeaeaf67", size = 414191, upload-time = "2026-04-25T11:08:31.16Z" }, - { url = "https://files.pythonhosted.org/packages/9c/42/85f5b79f4bf1ec7ba052491164adfd4f4e9519f5dc7246de4fbd64a1bd56/xxhash-3.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7ab9a49c410d8c6c786ab99e79c529938d894c01433130353dd0fe999111077a", size = 191604, upload-time = "2026-04-25T11:08:32.862Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d0/6127b623aa4cca18d8b7743592b048d689fd6c6e37ff26a22cddf6cd9d7f/xxhash-3.7.0-cp314-cp314-win32.whl", hash = "sha256:040ea63668f9185b92bc74942df09c7e65703deed71431333678fc6e739a9955", size = 31271, upload-time = "2026-04-25T11:08:34.651Z" }, - { url = "https://files.pythonhosted.org/packages/64/4f/44fc4788568004c43921701cbc127f48218a1eede2c9aea231115323564d/xxhash-3.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2a61e2a3fb23c892496d587b470dee7fa1b58b248a187719c65ea8e94ec13257", size = 32284, upload-time = "2026-04-25T11:08:35.987Z" }, - { url = "https://files.pythonhosted.org/packages/6d/77/18bb895eb60a49453d16e17d67990e5caff557c78eafc90ad4e2eabf4570/xxhash-3.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:c7741c7524961d8c0cb4d4c21b28957ff731a3fd5b5cd8b856dc80a40e9e5acc", size = 28701, upload-time = "2026-04-25T11:08:37.767Z" }, - { url = "https://files.pythonhosted.org/packages/45/a0/46f72244570c550fbbb7db1ef554183dd5ebe9136385f30e032b781ae8f6/xxhash-3.7.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fc84bf7aa7592f31ec63a3e7b11d624f468a3f19f5238cec7282a42e838ab1d7", size = 33646, upload-time = "2026-04-25T11:08:39.109Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3a/453846a7eceea11e75def361eed01ec6a0205b9822c19927ed364ccae7cc/xxhash-3.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9f1563fdc8abfc389748e6932c7e4e99c89a53e4ec37d4563c24fc06f5e5644b", size = 31125, upload-time = "2026-04-25T11:08:40.467Z" }, - { url = "https://files.pythonhosted.org/packages/bd/3e/49434aba738885d512f9e486db1bdd19db28dfa40372b56da26ef7a4e738/xxhash-3.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d415f18becf6f153046ab6adc97da77e3643a0ee205dae61c4012604113a020", size = 196633, upload-time = "2026-04-25T11:08:41.943Z" }, - { url = "https://files.pythonhosted.org/packages/a4/e9/006cb6127baeb9f8abe6d15e62faa01349f09b34e2bfd65175b2422d026b/xxhash-3.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb16aa13ed175bc9be5c2491ba031b85a9b51c4ed90e0b3d4ebe63cf3fb54f8e", size = 215899, upload-time = "2026-04-25T11:08:43.645Z" }, - { url = "https://files.pythonhosted.org/packages/27/e4/cc57d72e66df0ae29b914335f1c6dcf61e8f3746ddf0ae3c471aa4f15e00/xxhash-3.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f9fd595f1e5941b3d7863e4774e4b30caa6731fc34b9277da032295aa5656ee5", size = 238116, upload-time = "2026-04-25T11:08:45.698Z" }, - { url = "https://files.pythonhosted.org/packages/af/78/3531d4a3fd8a0038cc6be1f265a69c1b3587f557a10b677dd736de2202c1/xxhash-3.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1295325c5a98d552333fa53dc2b026b0ef0ec9c8e73ca3a952990b4c7d65d459", size = 215012, upload-time = "2026-04-25T11:08:47.355Z" }, - { url = "https://files.pythonhosted.org/packages/b4/f6/259fb1eaaec921f59b17203b0daee69829761226d3b980d5191d7723dd83/xxhash-3.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3573a651d146912da9daa9e29e5fbc45994420daaa9ef1e2fa5823e1dc485513", size = 448534, upload-time = "2026-04-25T11:08:49.149Z" }, - { url = "https://files.pythonhosted.org/packages/7b/16/a66d0eaf6a7e68532c07714361ddc904c663ec940f3b028c1ae4a21a7b9d/xxhash-3.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ec1e080a3d02d94ea9335bfab0e3374b877e25411422c18f51a943fa4b46381", size = 196217, upload-time = "2026-04-25T11:08:50.805Z" }, - { url = "https://files.pythonhosted.org/packages/8d/ef/d2efc7fc51756dc52509109d1a25cefc859d74bc4b19a167b12dbd8c2786/xxhash-3.7.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84415265192072d8638a3afc3c1bc5995e310570cd9acb54dc46d3939e364fe0", size = 286906, upload-time = "2026-04-25T11:08:52.418Z" }, - { url = "https://files.pythonhosted.org/packages/fc/67/25decd1d4a4018582ec4db2a868a2b7e40640f4adb20dfeb19ac923aa825/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d4dea659b57443989ef32f4295104fd6912c73d0bf26d1d148bb88a9f159b02", size = 213057, upload-time = "2026-04-25T11:08:54.105Z" }, - { url = "https://files.pythonhosted.org/packages/0d/5d/17651eb29d06786cdc40c60ae3d27d645aa5d61d2eca6237a7ba0b94789b/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:05ece0fe4d9c9c2728912d1981ae1566cfc83a011571b24732cbf76e1fb70dca", size = 243886, upload-time = "2026-04-25T11:08:56.109Z" }, - { url = "https://files.pythonhosted.org/packages/8a/d4/174d9cf7502243d586e6a9ae842b1ae23026620995114f85f1380e588bc9/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fd880353cf1ffaf321bc18dd663e111976dbd0d3bbd8a66d58d2b470dfa7f396", size = 201015, upload-time = "2026-04-25T11:08:57.777Z" }, - { url = "https://files.pythonhosted.org/packages/91/8c/2254e2d06c3ac5e6fe22eaf3da791b87ea823ae9f2c17b4af66755c5752d/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4e15cc9e2817f6481160f930c62842b3ff419e20e13072bcbab12230943092bc", size = 213457, upload-time = "2026-04-25T11:08:59.826Z" }, - { url = "https://files.pythonhosted.org/packages/79/a2/e3daa762545921173e3360f3b4ff7fc63c2d27359f7230ec1a7a74e117f6/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:90b9d1a8bd37d768ffc92a1f651ec69afc532a96fa1ac2ea7abbed5d630b3237", size = 277738, upload-time = "2026-04-25T11:09:01.423Z" }, - { url = "https://files.pythonhosted.org/packages/e1/4c/e186da2c46b87f5204640e008d42730bf3c1ee9f0efb71ae1ebcdfeac681/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:157c49475b34ecea8809e51123d9769a534e139d1247942f7a4bc67710bb2533", size = 417127, upload-time = "2026-04-25T11:09:03.592Z" }, - { url = "https://files.pythonhosted.org/packages/17/28/3798e15007a3712d0da3d3fe70f8e11916569858b5cc371053bc26270832/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5a6ddec83325685e729ca119d1f5c518ec39294212ecd770e60693cdc5f7eb79", size = 193962, upload-time = "2026-04-25T11:09:06.228Z" }, - { url = "https://files.pythonhosted.org/packages/ad/95/a26baa93b5241fd7630998816a4ec47a5a0bad193b3f8fc8f3593e1a4a67/xxhash-3.7.0-cp314-cp314t-win32.whl", hash = "sha256:a04a6cab47e2166435aaf5b9e5ee41d1532cc8300efdef87f2a4d0acb7db19ed", size = 31643, upload-time = "2026-04-25T11:09:08.153Z" }, - { url = "https://files.pythonhosted.org/packages/44/36/5454f13c447e395f9b06a3e91274c59f503d31fad84e1836efe3bdb71f6a/xxhash-3.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8653dd7c2eda020545bb2c71c7f7039b53fe7434d0fc1a0a9deb79ab3f1a4fc1", size = 32522, upload-time = "2026-04-25T11:09:09.534Z" }, - { url = "https://files.pythonhosted.org/packages/74/35/698e7e3ff38e22992ea24870a511d8762474fb6783627a2910ff22a185c2/xxhash-3.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:468f0fc114faaa4b36699f8e328bbc3bb11dc418ba94ac52c26dd736d4b6c637", size = 28807, upload-time = "2026-04-25T11:09:11.234Z" }, -] - [[package]] name = "yarl" version = "1.22.0" @@ -3530,6 +1688,38 @@ 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" }, + { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" }, + { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" }, + { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" }, + { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" }, { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, @@ -3610,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" @@ -3633,60 +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/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" }, -]