From 9b365374e82db7c130b8d76a5d42a3f0b4329d2e Mon Sep 17 00:00:00 2001 From: Declan Brady Date: Tue, 23 Jun 2026 09:50:06 -0400 Subject: [PATCH 1/4] refactor(tutorials): migrate to the unified harness surface + renumber Retire the duplicate pre-unified `harness_*` tutorials and migrate every tutorial onto the canonical unified harness surface (UnifiedEmitter / Turn / convert_* helpers). Renumber onto the `NNN_` paradigm, fixing the 060/130/140 collision; codex takes fresh 070/140/150 slots. Non-breaking: example sources only; no shipped SDK API changes. The unified surface already exists; the deprecated tracing handlers are still present and are removed in a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tutorials/00_sync/030_langgraph/README.md | 49 ++-- .../tutorials/00_sync/030_langgraph/graph.png | Bin 16357 -> 0 bytes .../00_sync/030_langgraph/manifest.yaml | 4 +- .../00_sync/030_langgraph/project/acp.py | 65 +++--- .../00_sync/030_langgraph/project/graph.py | 18 +- .../00_sync/030_langgraph/project/tools.py | 10 +- .../00_sync/030_langgraph/pyproject.toml | 2 +- .../00_sync/030_langgraph/tests/test_agent.py | 31 +-- .../00_sync/040_pydantic_ai/README.md | 72 +++--- .../00_sync/040_pydantic_ai/manifest.yaml | 4 +- .../00_sync/040_pydantic_ai/project/acp.py | 42 ++-- .../00_sync/040_pydantic_ai/project/agent.py | 8 +- .../00_sync/040_pydantic_ai/project/tools.py | 6 +- .../00_sync/040_pydantic_ai/pyproject.toml | 2 +- .../040_pydantic_ai/tests/test_agent.py | 20 +- .../.dockerignore | 0 .../Dockerfile | 12 +- .../README.md | 4 +- .../manifest.yaml | 10 +- .../project/__init__.py | 0 .../project/acp.py | 0 .../project/agent.py | 0 .../project/tools.py | 0 .../pyproject.toml | 2 +- .../tests/test_agent.py | 0 .../Dockerfile | 50 ---- .../050_openai_agents_local_sandbox/README.md | 113 ---------- .../manifest.yaml | 61 ----- .../project/acp.py | 77 ------- .../project/agent.py | 92 -------- .../project/tools.py | 29 --- .../pyproject.toml | 36 --- .../tests/test_agent.py | 148 ------------ .../00_sync/060_harness_openai/Dockerfile | 50 ---- .../.dockerignore | 0 .../{harness_codex => 070_codex}/Dockerfile | 12 +- .../{harness_codex => 070_codex}/README.md | 4 +- .../{harness_codex => 070_codex}/conftest.py | 0 .../manifest.yaml | 10 +- .../project/__init__.py | 0 .../project/acp.py | 0 .../pyproject.toml | 2 +- .../tests/test_agent.py | 2 +- .../00_sync/harness_langgraph/README.md | 55 ----- .../00_sync/harness_langgraph/manifest.yaml | 58 ----- .../00_sync/harness_langgraph/project/acp.py | 107 --------- .../harness_langgraph/project/graph.py | 67 ------ .../harness_langgraph/project/tools.py | 24 -- .../00_sync/harness_langgraph/pyproject.toml | 37 --- .../harness_langgraph/tests/test_agent.py | 144 ------------ .../00_sync/harness_pydantic_ai/Dockerfile | 50 ---- .../00_sync/harness_pydantic_ai/README.md | 54 ----- .../00_sync/harness_pydantic_ai/manifest.yaml | 58 ----- .../harness_pydantic_ai/project/acp.py | 92 -------- .../harness_pydantic_ai/project/agent.py | 39 ---- .../harness_pydantic_ai/project/tools.py | 20 -- .../harness_pydantic_ai/pyproject.toml | 36 --- .../harness_pydantic_ai/tests/test_agent.py | 138 ------------ .../10_async/00_base/100_langgraph/README.md | 58 ++--- .../10_async/00_base/100_langgraph/graph.png | Bin 16357 -> 0 bytes .../00_base/100_langgraph/manifest.yaml | 4 +- .../00_base/100_langgraph/project/acp.py | 53 +++-- .../00_base/100_langgraph/project/graph.py | 11 +- .../00_base/100_langgraph/project/tools.py | 10 +- .../00_base/100_langgraph/pyproject.toml | 2 +- .../00_base/100_langgraph/tests/test_agent.py | 35 +-- .../00_base/110_pydantic_ai/README.md | 87 ++++--- .../00_base/110_pydantic_ai/manifest.yaml | 6 +- .../00_base/110_pydantic_ai/project/acp.py | 57 +++-- .../00_base/110_pydantic_ai/project/agent.py | 8 +- .../00_base/110_pydantic_ai/project/tools.py | 6 +- .../00_base/110_pydantic_ai/pyproject.toml | 2 +- .../110_pydantic_ai/tests/test_agent.py | 26 +-- .../00_base/120_openai_agents}/.dockerignore | 0 .../Dockerfile | 12 +- .../README.md | 2 +- .../manifest.yaml | 10 +- .../120_openai_agents}/project/__init__.py | 0 .../project/acp.py | 0 .../project/agent.py | 0 .../project/tools.py | 0 .../pyproject.toml | 2 +- .../tests/test_agent.py | 0 .../Dockerfile | 50 ---- .../120_openai_agents_local_sandbox/README.md | 119 ---------- .../manifest.yaml | 61 ----- .../project/acp.py | 149 ------------ .../project/agent.py | 95 -------- .../project/tools.py | 29 --- .../pyproject.toml | 36 --- .../tests/test_agent.py | 122 ---------- .../00_base/130_harness_openai/Dockerfile | 50 ---- .../130_harness_openai/project/__init__.py | 0 .../.dockerignore | 0 .../{harness_codex => 140_codex}/Dockerfile | 12 +- .../{harness_codex => 140_codex}/README.md | 4 +- .../{harness_codex => 140_codex}/conftest.py | 0 .../manifest.yaml | 10 +- .../00_base/140_codex}/project/__init__.py | 0 .../project/acp.py | 0 .../pyproject.toml | 2 +- .../tests/test_agent.py | 2 +- .../00_base/harness_codex/project/__init__.py | 0 .../00_base/harness_langgraph/README.md | 57 ----- .../00_base/harness_langgraph/manifest.yaml | 58 ----- .../harness_langgraph/project/__init__.py | 0 .../00_base/harness_langgraph/project/acp.py | 109 --------- .../harness_langgraph/project/graph.py | 67 ------ .../harness_langgraph/project/tools.py | 24 -- .../00_base/harness_langgraph/pyproject.toml | 37 --- .../harness_langgraph/tests/test_agent.py | 100 -------- .../00_base/harness_pydantic_ai/Dockerfile | 50 ---- .../00_base/harness_pydantic_ai/README.md | 54 ----- .../00_base/harness_pydantic_ai/manifest.yaml | 58 ----- .../harness_pydantic_ai/project/__init__.py | 0 .../harness_pydantic_ai/project/acp.py | 159 ------------- .../harness_pydantic_ai/project/agent.py | 39 ---- .../harness_pydantic_ai/project/tools.py | 20 -- .../harness_pydantic_ai/pyproject.toml | 36 --- .../harness_pydantic_ai/tests/test_agent.py | 118 ---------- .../10_temporal/110_pydantic_ai/README.md | 194 ++++------------ .../10_temporal/110_pydantic_ai/manifest.yaml | 6 +- .../110_pydantic_ai/project/acp.py | 6 +- .../110_pydantic_ai/project/agent.py | 71 +++--- .../110_pydantic_ai/project/run_worker.py | 20 +- .../110_pydantic_ai/project/tools.py | 5 +- .../110_pydantic_ai/project/workflow.py | 49 ++-- .../110_pydantic_ai/pyproject.toml | 2 +- .../110_pydantic_ai/tests/test_agent.py | 38 +--- .../120_openai_agents}/.dockerignore | 0 .../Dockerfile | 12 +- .../README.md | 2 +- .../environments.yaml | 0 .../manifest.yaml | 14 +- .../120_openai_agents}/project/__init__.py | 0 .../project/acp.py | 0 .../project/activities.py | 6 +- .../project/agent.py | 0 .../project/run_worker.py | 4 +- .../project/tools.py | 0 .../project/workflow.py | 6 +- .../pyproject.toml | 2 +- .../tests/test_agent.py | 0 .../.dockerignore | 43 ---- .../Dockerfile | 62 ----- .../120_openai_agents_local_sandbox/README.md | 130 ----------- .../manifest.yaml | 111 --------- .../project/__init__.py | 0 .../project/acp.py | 83 ------- .../project/run_worker.py | 80 ------- .../project/workflow.py | 213 ------------------ .../pyproject.toml | 36 --- .../tests/test_agent.py | 144 ------------ .../10_temporal/130_langgraph/.dockerignore | 2 +- .../10_temporal/130_langgraph/.env.example | 13 -- .../10_temporal/130_langgraph/README.md | 79 +++---- .../10_temporal/130_langgraph/dev.ipynb | 126 ----------- .../130_langgraph/environments.yaml | 64 ------ .../10_temporal/130_langgraph/manifest.yaml | 93 +------- .../10_temporal/130_langgraph/project/acp.py | 26 +-- .../130_langgraph/project/graph.py | 43 +--- .../130_langgraph/project/run_worker.py | 12 +- .../130_langgraph/project/tools.py | 41 +++- .../130_langgraph/project/workflow.py | 9 +- .../10_temporal/130_langgraph/pyproject.toml | 6 +- .../130_langgraph/tests/test_agent.py | 33 +-- .../tests/test_graph_temporal.py | 105 --------- .../140_harness_openai/.dockerignore | 43 ---- .../10_temporal/140_harness_openai/Dockerfile | 43 ---- .../140_harness_openai/environments.yaml | 64 ------ .../140_harness_openai/project/__init__.py | 0 .../150_codex}/.dockerignore | 0 .../{harness_codex => 150_codex}/Dockerfile | 12 +- .../{harness_codex => 150_codex}/README.md | 4 +- .../{harness_codex => 150_codex}/conftest.py | 6 +- .../manifest.yaml | 14 +- .../150_codex}/project/__init__.py | 0 .../project/acp.py | 0 .../project/activities.py | 0 .../project/run_worker.py | 0 .../project/workflow.py | 0 .../pyproject.toml | 2 +- .../tests/test_agent.py | 2 +- .../harness_codex/project/__init__.py | 0 .../10_temporal/harness_langgraph/README.md | 53 ----- .../harness_langgraph/manifest.yaml | 51 ----- .../harness_langgraph/project/__init__.py | 0 .../harness_langgraph/project/acp.py | 34 --- .../harness_langgraph/project/graph.py | 85 ------- .../harness_langgraph/project/run_worker.py | 46 ---- .../harness_langgraph/project/tools.py | 37 --- .../harness_langgraph/project/workflow.py | 80 ------- .../harness_langgraph/pyproject.toml | 40 ---- .../harness_langgraph/tests/test_agent.py | 106 --------- .../harness_pydantic_ai/.dockerignore | 43 ---- .../harness_pydantic_ai/Dockerfile | 43 ---- .../10_temporal/harness_pydantic_ai/README.md | 61 ----- .../harness_pydantic_ai/manifest.yaml | 62 ----- .../harness_pydantic_ai/project/__init__.py | 0 .../harness_pydantic_ai/project/acp.py | 35 --- .../harness_pydantic_ai/project/agent.py | 111 --------- .../harness_pydantic_ai/project/run_worker.py | 48 ---- .../harness_pydantic_ai/project/tools.py | 24 -- .../harness_pydantic_ai/project/workflow.py | 137 ----------- .../harness_pydantic_ai/pyproject.toml | 38 ---- .../harness_pydantic_ai/tests/test_agent.py | 114 ---------- 206 files changed, 678 insertions(+), 6895 deletions(-) delete mode 100644 examples/tutorials/00_sync/030_langgraph/graph.png rename examples/tutorials/00_sync/{050_openai_agents_local_sandbox => 050_openai_agents}/.dockerignore (100%) rename examples/tutorials/00_sync/{harness_langgraph => 050_openai_agents}/Dockerfile (73%) rename examples/tutorials/00_sync/{060_harness_openai => 050_openai_agents}/README.md (85%) rename examples/tutorials/00_sync/{060_harness_openai => 050_openai_agents}/manifest.yaml (84%) rename examples/tutorials/00_sync/{050_openai_agents_local_sandbox => 050_openai_agents}/project/__init__.py (100%) rename examples/tutorials/00_sync/{060_harness_openai => 050_openai_agents}/project/acp.py (100%) rename examples/tutorials/00_sync/{060_harness_openai => 050_openai_agents}/project/agent.py (100%) rename examples/tutorials/00_sync/{060_harness_openai => 050_openai_agents}/project/tools.py (100%) rename examples/tutorials/00_sync/{060_harness_openai => 050_openai_agents}/pyproject.toml (95%) rename examples/tutorials/00_sync/{060_harness_openai => 050_openai_agents}/tests/test_agent.py (100%) delete mode 100644 examples/tutorials/00_sync/050_openai_agents_local_sandbox/Dockerfile delete mode 100644 examples/tutorials/00_sync/050_openai_agents_local_sandbox/README.md delete mode 100644 examples/tutorials/00_sync/050_openai_agents_local_sandbox/manifest.yaml delete mode 100644 examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/acp.py delete mode 100644 examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/agent.py delete mode 100644 examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/tools.py delete mode 100644 examples/tutorials/00_sync/050_openai_agents_local_sandbox/pyproject.toml delete mode 100644 examples/tutorials/00_sync/050_openai_agents_local_sandbox/tests/test_agent.py delete mode 100644 examples/tutorials/00_sync/060_harness_openai/Dockerfile rename examples/tutorials/00_sync/{060_harness_openai => 070_codex}/.dockerignore (100%) rename examples/tutorials/00_sync/{harness_codex => 070_codex}/Dockerfile (74%) rename examples/tutorials/00_sync/{harness_codex => 070_codex}/README.md (95%) rename examples/tutorials/00_sync/{harness_codex => 070_codex}/conftest.py (100%) rename examples/tutorials/00_sync/{harness_codex => 070_codex}/manifest.yaml (86%) rename examples/tutorials/00_sync/{060_harness_openai => 070_codex}/project/__init__.py (100%) rename examples/tutorials/00_sync/{harness_codex => 070_codex}/project/acp.py (100%) rename examples/tutorials/00_sync/{harness_codex => 070_codex}/pyproject.toml (96%) rename examples/tutorials/00_sync/{harness_codex => 070_codex}/tests/test_agent.py (99%) delete mode 100644 examples/tutorials/00_sync/harness_langgraph/README.md delete mode 100644 examples/tutorials/00_sync/harness_langgraph/manifest.yaml delete mode 100644 examples/tutorials/00_sync/harness_langgraph/project/acp.py delete mode 100644 examples/tutorials/00_sync/harness_langgraph/project/graph.py delete mode 100644 examples/tutorials/00_sync/harness_langgraph/project/tools.py delete mode 100644 examples/tutorials/00_sync/harness_langgraph/pyproject.toml delete mode 100644 examples/tutorials/00_sync/harness_langgraph/tests/test_agent.py delete mode 100644 examples/tutorials/00_sync/harness_pydantic_ai/Dockerfile delete mode 100644 examples/tutorials/00_sync/harness_pydantic_ai/README.md delete mode 100644 examples/tutorials/00_sync/harness_pydantic_ai/manifest.yaml delete mode 100644 examples/tutorials/00_sync/harness_pydantic_ai/project/acp.py delete mode 100644 examples/tutorials/00_sync/harness_pydantic_ai/project/agent.py delete mode 100644 examples/tutorials/00_sync/harness_pydantic_ai/project/tools.py delete mode 100644 examples/tutorials/00_sync/harness_pydantic_ai/pyproject.toml delete mode 100644 examples/tutorials/00_sync/harness_pydantic_ai/tests/test_agent.py delete mode 100644 examples/tutorials/10_async/00_base/100_langgraph/graph.png rename examples/tutorials/{00_sync/harness_pydantic_ai => 10_async/00_base/120_openai_agents}/.dockerignore (100%) rename examples/tutorials/10_async/00_base/{harness_langgraph => 120_openai_agents}/Dockerfile (70%) rename examples/tutorials/10_async/00_base/{130_harness_openai => 120_openai_agents}/README.md (92%) rename examples/tutorials/10_async/00_base/{130_harness_openai => 120_openai_agents}/manifest.yaml (82%) rename examples/tutorials/{00_sync/harness_codex => 10_async/00_base/120_openai_agents}/project/__init__.py (100%) rename examples/tutorials/10_async/00_base/{130_harness_openai => 120_openai_agents}/project/acp.py (100%) rename examples/tutorials/10_async/00_base/{130_harness_openai => 120_openai_agents}/project/agent.py (100%) rename examples/tutorials/10_async/00_base/{130_harness_openai => 120_openai_agents}/project/tools.py (100%) rename examples/tutorials/10_async/00_base/{130_harness_openai => 120_openai_agents}/pyproject.toml (95%) rename examples/tutorials/10_async/00_base/{130_harness_openai => 120_openai_agents}/tests/test_agent.py (100%) delete mode 100644 examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/Dockerfile delete mode 100644 examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/README.md delete mode 100644 examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/manifest.yaml delete mode 100644 examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/acp.py delete mode 100644 examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/agent.py delete mode 100644 examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/tools.py delete mode 100644 examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/pyproject.toml delete mode 100644 examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/tests/test_agent.py delete mode 100644 examples/tutorials/10_async/00_base/130_harness_openai/Dockerfile delete mode 100644 examples/tutorials/10_async/00_base/130_harness_openai/project/__init__.py rename examples/tutorials/10_async/00_base/{120_openai_agents_local_sandbox => 140_codex}/.dockerignore (100%) rename examples/tutorials/10_async/00_base/{harness_codex => 140_codex}/Dockerfile (64%) rename examples/tutorials/10_async/00_base/{harness_codex => 140_codex}/README.md (94%) rename examples/tutorials/10_async/00_base/{harness_codex => 140_codex}/conftest.py (100%) rename examples/tutorials/10_async/00_base/{harness_codex => 140_codex}/manifest.yaml (84%) rename examples/tutorials/{00_sync/harness_langgraph => 10_async/00_base/140_codex}/project/__init__.py (100%) rename examples/tutorials/10_async/00_base/{harness_codex => 140_codex}/project/acp.py (100%) rename examples/tutorials/10_async/00_base/{harness_codex => 140_codex}/pyproject.toml (96%) rename examples/tutorials/10_async/00_base/{harness_codex => 140_codex}/tests/test_agent.py (99%) delete mode 100644 examples/tutorials/10_async/00_base/harness_codex/project/__init__.py delete mode 100644 examples/tutorials/10_async/00_base/harness_langgraph/README.md delete mode 100644 examples/tutorials/10_async/00_base/harness_langgraph/manifest.yaml delete mode 100644 examples/tutorials/10_async/00_base/harness_langgraph/project/__init__.py delete mode 100644 examples/tutorials/10_async/00_base/harness_langgraph/project/acp.py delete mode 100644 examples/tutorials/10_async/00_base/harness_langgraph/project/graph.py delete mode 100644 examples/tutorials/10_async/00_base/harness_langgraph/project/tools.py delete mode 100644 examples/tutorials/10_async/00_base/harness_langgraph/pyproject.toml delete mode 100644 examples/tutorials/10_async/00_base/harness_langgraph/tests/test_agent.py delete mode 100644 examples/tutorials/10_async/00_base/harness_pydantic_ai/Dockerfile delete mode 100644 examples/tutorials/10_async/00_base/harness_pydantic_ai/README.md delete mode 100644 examples/tutorials/10_async/00_base/harness_pydantic_ai/manifest.yaml delete mode 100644 examples/tutorials/10_async/00_base/harness_pydantic_ai/project/__init__.py delete mode 100644 examples/tutorials/10_async/00_base/harness_pydantic_ai/project/acp.py delete mode 100644 examples/tutorials/10_async/00_base/harness_pydantic_ai/project/agent.py delete mode 100644 examples/tutorials/10_async/00_base/harness_pydantic_ai/project/tools.py delete mode 100644 examples/tutorials/10_async/00_base/harness_pydantic_ai/pyproject.toml delete mode 100644 examples/tutorials/10_async/00_base/harness_pydantic_ai/tests/test_agent.py rename examples/tutorials/10_async/{00_base/130_harness_openai => 10_temporal/120_openai_agents}/.dockerignore (100%) rename examples/tutorials/10_async/10_temporal/{harness_langgraph => 120_openai_agents}/Dockerfile (65%) rename examples/tutorials/10_async/10_temporal/{140_harness_openai => 120_openai_agents}/README.md (94%) rename examples/tutorials/10_async/10_temporal/{120_openai_agents_local_sandbox => 120_openai_agents}/environments.yaml (100%) rename examples/tutorials/10_async/10_temporal/{140_harness_openai => 120_openai_agents}/manifest.yaml (78%) rename examples/tutorials/{00_sync/harness_pydantic_ai => 10_async/10_temporal/120_openai_agents}/project/__init__.py (100%) rename examples/tutorials/10_async/10_temporal/{140_harness_openai => 120_openai_agents}/project/acp.py (100%) rename examples/tutorials/10_async/10_temporal/{140_harness_openai => 120_openai_agents}/project/activities.py (92%) rename examples/tutorials/10_async/10_temporal/{140_harness_openai => 120_openai_agents}/project/agent.py (100%) rename examples/tutorials/10_async/10_temporal/{140_harness_openai => 120_openai_agents}/project/run_worker.py (91%) rename examples/tutorials/10_async/10_temporal/{140_harness_openai => 120_openai_agents}/project/tools.py (100%) rename examples/tutorials/10_async/10_temporal/{140_harness_openai => 120_openai_agents}/project/workflow.py (97%) rename examples/tutorials/10_async/10_temporal/{140_harness_openai => 120_openai_agents}/pyproject.toml (95%) rename examples/tutorials/10_async/10_temporal/{140_harness_openai => 120_openai_agents}/tests/test_agent.py (100%) delete mode 100644 examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/.dockerignore delete mode 100644 examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/Dockerfile delete mode 100644 examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/README.md delete mode 100644 examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/manifest.yaml delete mode 100644 examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/__init__.py delete mode 100644 examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/acp.py delete mode 100644 examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/run_worker.py delete mode 100644 examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/workflow.py delete mode 100644 examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/pyproject.toml delete mode 100644 examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/tests/test_agent.py delete mode 100644 examples/tutorials/10_async/10_temporal/130_langgraph/.env.example delete mode 100644 examples/tutorials/10_async/10_temporal/130_langgraph/dev.ipynb delete mode 100644 examples/tutorials/10_async/10_temporal/130_langgraph/environments.yaml delete mode 100644 examples/tutorials/10_async/10_temporal/130_langgraph/tests/test_graph_temporal.py delete mode 100644 examples/tutorials/10_async/10_temporal/140_harness_openai/.dockerignore delete mode 100644 examples/tutorials/10_async/10_temporal/140_harness_openai/Dockerfile delete mode 100644 examples/tutorials/10_async/10_temporal/140_harness_openai/environments.yaml delete mode 100644 examples/tutorials/10_async/10_temporal/140_harness_openai/project/__init__.py rename examples/tutorials/10_async/{00_base/harness_pydantic_ai => 10_temporal/150_codex}/.dockerignore (100%) rename examples/tutorials/10_async/10_temporal/{harness_codex => 150_codex}/Dockerfile (66%) rename examples/tutorials/10_async/10_temporal/{harness_codex => 150_codex}/README.md (95%) rename examples/tutorials/10_async/10_temporal/{harness_codex => 150_codex}/conftest.py (72%) rename examples/tutorials/10_async/10_temporal/{harness_codex => 150_codex}/manifest.yaml (80%) rename examples/tutorials/10_async/{00_base/120_openai_agents_local_sandbox => 10_temporal/150_codex}/project/__init__.py (100%) rename examples/tutorials/10_async/10_temporal/{harness_codex => 150_codex}/project/acp.py (100%) rename examples/tutorials/10_async/10_temporal/{harness_codex => 150_codex}/project/activities.py (100%) rename examples/tutorials/10_async/10_temporal/{harness_codex => 150_codex}/project/run_worker.py (100%) rename examples/tutorials/10_async/10_temporal/{harness_codex => 150_codex}/project/workflow.py (100%) rename examples/tutorials/10_async/10_temporal/{harness_codex => 150_codex}/pyproject.toml (96%) rename examples/tutorials/10_async/10_temporal/{harness_codex => 150_codex}/tests/test_agent.py (99%) delete mode 100644 examples/tutorials/10_async/10_temporal/harness_codex/project/__init__.py delete mode 100644 examples/tutorials/10_async/10_temporal/harness_langgraph/README.md delete mode 100644 examples/tutorials/10_async/10_temporal/harness_langgraph/manifest.yaml delete mode 100644 examples/tutorials/10_async/10_temporal/harness_langgraph/project/__init__.py delete mode 100644 examples/tutorials/10_async/10_temporal/harness_langgraph/project/acp.py delete mode 100644 examples/tutorials/10_async/10_temporal/harness_langgraph/project/graph.py delete mode 100644 examples/tutorials/10_async/10_temporal/harness_langgraph/project/run_worker.py delete mode 100644 examples/tutorials/10_async/10_temporal/harness_langgraph/project/tools.py delete mode 100644 examples/tutorials/10_async/10_temporal/harness_langgraph/project/workflow.py delete mode 100644 examples/tutorials/10_async/10_temporal/harness_langgraph/pyproject.toml delete mode 100644 examples/tutorials/10_async/10_temporal/harness_langgraph/tests/test_agent.py delete mode 100644 examples/tutorials/10_async/10_temporal/harness_pydantic_ai/.dockerignore delete mode 100644 examples/tutorials/10_async/10_temporal/harness_pydantic_ai/Dockerfile delete mode 100644 examples/tutorials/10_async/10_temporal/harness_pydantic_ai/README.md delete mode 100644 examples/tutorials/10_async/10_temporal/harness_pydantic_ai/manifest.yaml delete mode 100644 examples/tutorials/10_async/10_temporal/harness_pydantic_ai/project/__init__.py delete mode 100644 examples/tutorials/10_async/10_temporal/harness_pydantic_ai/project/acp.py delete mode 100644 examples/tutorials/10_async/10_temporal/harness_pydantic_ai/project/agent.py delete mode 100644 examples/tutorials/10_async/10_temporal/harness_pydantic_ai/project/run_worker.py delete mode 100644 examples/tutorials/10_async/10_temporal/harness_pydantic_ai/project/tools.py delete mode 100644 examples/tutorials/10_async/10_temporal/harness_pydantic_ai/project/workflow.py delete mode 100644 examples/tutorials/10_async/10_temporal/harness_pydantic_ai/pyproject.toml delete mode 100644 examples/tutorials/10_async/10_temporal/harness_pydantic_ai/tests/test_agent.py diff --git a/examples/tutorials/00_sync/030_langgraph/README.md b/examples/tutorials/00_sync/030_langgraph/README.md index e5b1db0f7..5a68792cc 100644 --- a/examples/tutorials/00_sync/030_langgraph/README.md +++ b/examples/tutorials/00_sync/030_langgraph/README.md @@ -1,43 +1,50 @@ -# Tutorial 030: Sync LangGraph Agent +# Tutorial: Sync LangGraph Agent -This tutorial demonstrates how to build a **synchronous** LangGraph agent on AgentEx with: -- Tool calling (ReAct pattern) -- Streaming token output -- Multi-turn conversation memory via AgentEx checkpointer -- Tracing integration +This tutorial demonstrates how to build a **synchronous** LangGraph agent on AgentEx +using the **unified harness surface**: -## Graph Structure +```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 +``` -![Graph](graph.png) +The `LangGraphTurn` + `UnifiedEmitter` path replaces calling the lower-level +``convert_langgraph_to_agentex_events`` helper directly. ## Key Concepts -### Sync ACP -The sync ACP model uses HTTP request/response for communication. The `@acp.on_message_send` handler receives a message and yields streaming events back to the client. +### 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. -### 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 +### AGX1-377 Note -### Streaming -The agent streams tokens as they're generated using `convert_langgraph_to_agentex_events()`, which converts LangGraph's stream events into AgentEx `TaskMessageUpdate` events. +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 and message handler | -| `project/graph.py` | LangGraph state graph definition | +| `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 | +| `manifest.yaml` | Agent configuration (name: s030-langgraph) | ## Running Locally ```bash -# From this directory agentex agents run ``` diff --git a/examples/tutorials/00_sync/030_langgraph/graph.png b/examples/tutorials/00_sync/030_langgraph/graph.png deleted file mode 100644 index 16d22a1e7ec819b0f0520a1347c729ca6adcbe5a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16357 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 TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]: - """Handle incoming messages from Agentex, streaming tokens and tool calls.""" + """Handle incoming messages, streaming tokens and tool calls via unified harness.""" graph = await get_graph() - thread_id = params.task.id + task_id = params.task.id user_message = params.content.content - logger.info(f"Processing message for thread {thread_id}") + logger.info(f"Processing message for task {task_id}") async with adk.tracing.span( - trace_id=thread_id, + trace_id=task_id, + task_id=task_id, name="message", input={"message": user_message}, data={"__span_type__": "AGENT_WORKFLOW"}, ) as turn_span: - callback = create_langgraph_tracing_handler( - trace_id=thread_id, - parent_span_id=turn_span.id if turn_span else None, - ) - stream = graph.astream( {"messages": [{"role": "user", "content": user_message}]}, - config={ - "configurable": {"thread_id": thread_id}, - "callbacks": [callback], - }, + 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 convert_langgraph_to_agentex_events(stream): - # Accumulate text deltas for span output + 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} + 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 index 53728cd58..6709719e5 100644 --- a/examples/tutorials/00_sync/030_langgraph/project/graph.py +++ b/examples/tutorials/00_sync/030_langgraph/project/graph.py @@ -1,8 +1,7 @@ -""" -LangGraph graph definition. +"""LangGraph graph definition for the 030_langgraph sync agent. -Defines the state, nodes, edges, and compiles the graph. -The compiled graph is the boundary between this module and the API layer. +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 @@ -35,15 +34,12 @@ 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. - """ + """Create and compile the agent graph with checkpointer.""" llm = ChatOpenAI( model=MODEL_NAME, reasoning={"effort": "high", "summary": "auto"}, @@ -56,9 +52,7 @@ 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") - ) + 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]} diff --git a/examples/tutorials/00_sync/030_langgraph/project/tools.py b/examples/tutorials/00_sync/030_langgraph/project/tools.py index 1b402a906..b3e5dba34 100644 --- a/examples/tutorials/00_sync/030_langgraph/project/tools.py +++ b/examples/tutorials/00_sync/030_langgraph/project/tools.py @@ -1,9 +1,4 @@ -""" -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. -""" +"""Tool definitions for the 030_langgraph sync agent.""" from langchain_core.tools import Tool @@ -17,16 +12,13 @@ def get_weather(city: str) -> str: Returns: A string describing the weather conditions. """ - # TODO: Replace with actual weather API call return f"The weather in {city} is sunny and 72°F" -# Define tools weather_tool = Tool( name="get_weather", func=get_weather, description="Get the current weather for a city. Input should be a city name.", ) -# Export all tools as a list TOOLS = [weather_tool] diff --git a/examples/tutorials/00_sync/030_langgraph/pyproject.toml b/examples/tutorials/00_sync/030_langgraph/pyproject.toml index fc9f99971..33bea16b5 100644 --- a/examples/tutorials/00_sync/030_langgraph/pyproject.toml +++ b/examples/tutorials/00_sync/030_langgraph/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "s030-langgraph" version = "0.1.0" -description = "A sync LangGraph agent with tool calling and streaming" +description = "A sync LangGraph agent using the unified harness surface" readme = "README.md" requires-python = ">=3.12" dependencies = [ diff --git a/examples/tutorials/00_sync/030_langgraph/tests/test_agent.py b/examples/tutorials/00_sync/030_langgraph/tests/test_agent.py index 36fcf418f..dabd83e76 100644 --- a/examples/tutorials/00_sync/030_langgraph/tests/test_agent.py +++ b/examples/tutorials/00_sync/030_langgraph/tests/test_agent.py @@ -1,14 +1,8 @@ """ -Tests for the sync LangGraph agent. +Tests for the sync harness LangGraph agent. -This test suite validates: -- Non-streaming message sending with tool-calling LangGraph agent -- Streaming message sending with token-by-token output - -To run these tests: -1. Make sure the agent is running (via docker-compose or `agentex agents run`) -2. Set the AGENTEX_API_BASE_URL environment variable if not using default -3. Run: pytest test_agent.py -v +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) @@ -25,26 +19,22 @@ from agentex.types.agent_rpc_params import ParamsCreateTaskRequest, ParamsSendMessageRequest from agentex.lib.sdk.fastacp.base.base_acp_server import uuid -# Configuration from environment variables AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") AGENT_NAME = os.environ.get("AGENT_NAME", "s030-langgraph") @pytest.fixture def client(): - """Create an AgentEx client instance for testing.""" return Agentex(base_url=AGENTEX_API_BASE_URL) @pytest.fixture def agent_name(): - """Return the agent name for testing.""" return AGENT_NAME @pytest.fixture def agent_id(client, agent_name): - """Retrieve the agent ID based on the agent name.""" agents = client.agents.list() for agent in agents: if agent.name == agent_name: @@ -53,10 +43,7 @@ def agent_id(client, agent_name): class TestNonStreamingMessages: - """Test non-streaming message sending with LangGraph agent.""" - def test_send_simple_message(self, client: Agentex, agent_name: str): - """Test sending a simple message and receiving a response.""" response = client.agents.send_message( agent_name=agent_name, params=ParamsSendMessageRequest( @@ -72,7 +59,6 @@ def test_send_simple_message(self, client: Agentex, agent_name: str): 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( @@ -88,12 +74,10 @@ def test_tool_calling(self, client: Agentex, agent_name: str): assert len(result) >= 1 def test_multiturn_conversation(self, client: Agentex, agent_name: str, agent_id: str): - """Test multi-turn conversation with memory via LangGraph checkpointer.""" task_response = client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) task = task_response.result assert task is not None - # First message response1 = client.agents.send_message( agent_name=agent_name, params=ParamsSendMessageRequest( @@ -107,7 +91,6 @@ def test_multiturn_conversation(self, client: Agentex, agent_name: str, agent_id ) assert response1.result is not None - # Second message - agent should remember the name response2 = client.agents.send_message( agent_name=agent_name, params=ParamsSendMessageRequest( @@ -126,10 +109,7 @@ def test_multiturn_conversation(self, client: Agentex, agent_name: str, agent_id class TestStreamingMessages: - """Test streaming message sending with LangGraph agent.""" - def test_stream_simple_message(self, client: Agentex, agent_name: str): - """Test streaming a simple message response.""" stream = client.agents.send_message_stream( agent_name=agent_name, params=ParamsSendMessageRequest( @@ -140,14 +120,11 @@ def test_stream_simple_message(self, client: Agentex, agent_name: str): ) ), ) - aggregated_content, chunks = collect_streaming_response(stream) - assert aggregated_content is not None assert len(chunks) > 1, "No chunks received in streaming response." def test_stream_tool_calling(self, client: Agentex, agent_name: str): - """Test streaming with tool calls.""" stream = client.agents.send_message_stream( agent_name=agent_name, params=ParamsSendMessageRequest( @@ -158,9 +135,7 @@ def test_stream_tool_calling(self, client: Agentex, agent_name: str): ) ), ) - aggregated_content, chunks = collect_streaming_response(stream) - assert aggregated_content is not None assert len(chunks) > 0, "No chunks received in streaming response." diff --git a/examples/tutorials/00_sync/040_pydantic_ai/README.md b/examples/tutorials/00_sync/040_pydantic_ai/README.md index 02c3b57c7..ef52c7c77 100644 --- a/examples/tutorials/00_sync/040_pydantic_ai/README.md +++ b/examples/tutorials/00_sync/040_pydantic_ai/README.md @@ -1,46 +1,52 @@ -# Tutorial 040: Sync Pydantic AI Agent +# Sync Pydantic AI Agent -This tutorial demonstrates how to build a **synchronous** Pydantic AI agent on AgentEx with: -- Tool calling (Pydantic AI handles the tool loop internally) -- Streaming token output (including token-by-token tool-call argument streaming) +A minimal **synchronous** Pydantic AI agent that drives the **unified harness +surface** (`UnifiedEmitter.yield_turn` + `PydanticAITurn`) on the sync +(HTTP-yield) channel. -## Key Concepts +## Why this agent exists -### Sync ACP -The sync ACP model uses HTTP request/response for communication. The `@acp.on_message_send` handler receives a message and yields streaming events back to the client. +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. -### Pydantic AI Integration -- **Agent**: A single `pydantic_ai.Agent` that owns the model and tools. No graph required — Pydantic AI runs its own tool-call loop until the model is done. -- **`@agent.tool_plain`**: Registers a Python function as a tool. Pydantic AI infers the schema from type hints and docstring. -- **`agent.run_stream_events(...)`**: Yields `AgentStreamEvent`s (PartStartEvent / PartDeltaEvent / PartEndEvent / FunctionToolResultEvent) as the model produces them. +## How it wires the unified surface -### Streaming -The agent streams tokens and tool-call arguments as they're generated using `convert_pydantic_ai_to_agentex_events()`, which adapts Pydantic AI's stream into AgentEx `TaskMessageUpdate` events. Notably, **tool-call arguments stream as `ToolRequestDelta` tokens** rather than arriving as a single complete payload — a richer experience than what OpenAI Agents SDK currently exposes. +In `project/acp.py`: -## Files +```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 +``` -| File | Description | -|------|-------------| -| `project/acp.py` | ACP server and message handler | -| `project/agent.py` | Pydantic AI agent + tool registration | -| `project/tools.py` | Tool definitions (weather example) | -| `tests/test_agent.py` | Integration tests | -| `manifest.yaml` | Agent configuration | +- `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. -## Running Locally +## Files -```bash -# From this directory -agentex agents run -``` +- `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). -## Running Tests +## Tools -```bash -pytest tests/test_agent.py -v -``` +- `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. -## Notes +## Offline coverage -- Multi-turn conversation memory is not wired in this tutorial. Pydantic AI does not ship a checkpointer like LangGraph; to add memory, load prior messages via `adk.messages.list(task_id=...)` and pass them to `agent.run_stream_events(..., message_history=...)`. -- Reasoning/thinking tokens are not exercised here because `gpt-4o-mini` does not emit `ThinkingPart`s. Swap to a reasoning-capable model (e.g. `openai:o1-mini` via Pydantic AI's appropriate provider) if you want to test that branch end-to-end. +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 index 68d3b4a00..9563de39c 100644 --- a/examples/tutorials/00_sync/040_pydantic_ai/manifest.yaml +++ b/examples/tutorials/00_sync/040_pydantic_ai/manifest.yaml @@ -17,7 +17,7 @@ local_development: agent: acp_type: sync name: s040-pydantic-ai - description: A sync Pydantic AI agent with tool calling and streaming + description: A sync Pydantic AI harness test agent using the unified emitter surface temporal: enabled: false @@ -47,7 +47,7 @@ deployment: global: agent: name: "s040-pydantic-ai" - description: "A sync Pydantic AI agent with tool calling and streaming" + description: "A sync Pydantic AI harness test agent using the unified emitter surface" replicaCount: 1 resources: requests: diff --git a/examples/tutorials/00_sync/040_pydantic_ai/project/acp.py b/examples/tutorials/00_sync/040_pydantic_ai/project/acp.py index 0c096893f..f23cd7960 100644 --- a/examples/tutorials/00_sync/040_pydantic_ai/project/acp.py +++ b/examples/tutorials/00_sync/040_pydantic_ai/project/acp.py @@ -1,7 +1,17 @@ -"""ACP (Agent Communication Protocol) handler for Agentex. - -This is the API layer — it owns the agent lifecycle and streams tokens -and tool calls from the Pydantic AI agent to the Agentex frontend. +"""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 @@ -14,17 +24,15 @@ load_dotenv() import agentex.lib.adk as adk -from project.agent import create_agent -from agentex.lib.adk import ( - create_pydantic_ai_tracing_handler, - convert_pydantic_ai_to_agentex_events, -) +from 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__) @@ -54,7 +62,7 @@ def get_agent(): async def handle_message_send( params: SendMessageParams, ) -> TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]: - """Handle incoming messages from Agentex, streaming tokens and tool calls.""" + """Handle incoming messages, streaming events through the unified surface.""" agent = get_agent() task_id = params.task.id @@ -68,11 +76,17 @@ async def handle_message_send( input={"message": user_message}, data={"__span_type__": "AGENT_WORKFLOW"}, ) as turn_span: - tracing_handler = create_pydantic_ai_tracing_handler( + # 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, - task_id=task_id, ) + async with agent.run_stream_events(user_message) as stream: - async for event in convert_pydantic_ai_to_agentex_events(stream, tracing_handler=tracing_handler): - yield event + # 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 index 2c0f6f10c..72fd74173 100644 --- a/examples/tutorials/00_sync/040_pydantic_ai/project/agent.py +++ b/examples/tutorials/00_sync/040_pydantic_ai/project/agent.py @@ -1,4 +1,4 @@ -"""Pydantic AI agent definition. +"""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. @@ -12,6 +12,8 @@ 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. @@ -29,9 +31,7 @@ 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") - ), + system_prompt=SYSTEM_PROMPT.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")), ) agent.tool_plain(get_weather) diff --git a/examples/tutorials/00_sync/040_pydantic_ai/project/tools.py b/examples/tutorials/00_sync/040_pydantic_ai/project/tools.py index bab87942a..d649c75f1 100644 --- a/examples/tutorials/00_sync/040_pydantic_ai/project/tools.py +++ b/examples/tutorials/00_sync/040_pydantic_ai/project/tools.py @@ -1,8 +1,8 @@ -"""Tool definitions for the Pydantic AI agent. +"""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 functions so they're -easy to unit-test in isolation. +(see project.agent). This module hosts the bare function so it is easy to +unit-test in isolation. """ from __future__ import annotations diff --git a/examples/tutorials/00_sync/040_pydantic_ai/pyproject.toml b/examples/tutorials/00_sync/040_pydantic_ai/pyproject.toml index 3e645fa15..748a9f3cb 100644 --- a/examples/tutorials/00_sync/040_pydantic_ai/pyproject.toml +++ b/examples/tutorials/00_sync/040_pydantic_ai/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "s040-pydantic-ai" version = "0.1.0" -description = "A sync Pydantic AI agent with tool calling and streaming" +description = "A sync Pydantic AI harness test agent using the unified emitter surface" readme = "README.md" requires-python = ">=3.12" dependencies = [ 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 index d3deed1c7..4aad12a56 100644 --- a/examples/tutorials/00_sync/040_pydantic_ai/tests/test_agent.py +++ b/examples/tutorials/00_sync/040_pydantic_ai/tests/test_agent.py @@ -1,8 +1,10 @@ -"""Tests for the sync Pydantic AI agent. +"""Live tests for the sync Pydantic AI agent. -This test suite validates: -- Non-streaming message sending with tool-calling Pydantic AI agent -- Streaming message sending with token-by-token output +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`) @@ -50,7 +52,7 @@ def agent_id(client, agent_name): class TestNonStreamingMessages: - """Test non-streaming message sending with Pydantic AI agent.""" + """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.""" @@ -86,7 +88,7 @@ def test_tool_calling(self, client: Agentex, agent_name: str): class TestStreamingMessages: - """Test streaming message sending with Pydantic AI agent.""" + """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.""" @@ -107,10 +109,10 @@ def test_stream_simple_message(self, client: Agentex, agent_name: str): 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. + """Test streaming with tool calls through the unified surface. - This exercises the headline Pydantic AI converter feature: - tool-call argument tokens streaming through as ToolRequestDelta. + 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, diff --git a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/.dockerignore b/examples/tutorials/00_sync/050_openai_agents/.dockerignore similarity index 100% rename from examples/tutorials/00_sync/050_openai_agents_local_sandbox/.dockerignore rename to examples/tutorials/00_sync/050_openai_agents/.dockerignore diff --git a/examples/tutorials/00_sync/harness_langgraph/Dockerfile b/examples/tutorials/00_sync/050_openai_agents/Dockerfile similarity index 73% rename from examples/tutorials/00_sync/harness_langgraph/Dockerfile rename to examples/tutorials/00_sync/050_openai_agents/Dockerfile index 9d492198f..c9ccd6f54 100644 --- a/examples/tutorials/00_sync/harness_langgraph/Dockerfile +++ b/examples/tutorials/00_sync/050_openai_agents/Dockerfile @@ -23,16 +23,16 @@ 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/harness_langgraph/pyproject.toml /app/harness_langgraph/pyproject.toml -COPY 00_sync/harness_langgraph/README.md /app/harness_langgraph/README.md +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/harness_langgraph +WORKDIR /app/050_openai_agents # Copy the project code -COPY 00_sync/harness_langgraph/project /app/harness_langgraph/project +COPY 00_sync/050_openai_agents/project /app/050_openai_agents/project # Copy the test files -COPY 00_sync/harness_langgraph/tests /app/harness_langgraph/tests +COPY 00_sync/050_openai_agents/tests /app/050_openai_agents/tests # Copy shared test utilities COPY test_utils /app/test_utils @@ -44,7 +44,7 @@ RUN uv pip install --system .[dev] ENV PYTHONPATH=/app # Set test environment variables -ENV AGENT_NAME=s-harness-langgraph +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/060_harness_openai/README.md b/examples/tutorials/00_sync/050_openai_agents/README.md similarity index 85% rename from examples/tutorials/00_sync/060_harness_openai/README.md rename to examples/tutorials/00_sync/050_openai_agents/README.md index e22e9aa8b..98cec3f9a 100644 --- a/examples/tutorials/00_sync/060_harness_openai/README.md +++ b/examples/tutorials/00_sync/050_openai_agents/README.md @@ -9,8 +9,8 @@ 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 (`130_harness_openai`) and temporal -(`140_harness_openai`) variants; only the delivery method differs. +`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) diff --git a/examples/tutorials/00_sync/060_harness_openai/manifest.yaml b/examples/tutorials/00_sync/050_openai_agents/manifest.yaml similarity index 84% rename from examples/tutorials/00_sync/060_harness_openai/manifest.yaml rename to examples/tutorials/00_sync/050_openai_agents/manifest.yaml index 4967c1f8d..bdb47e8d8 100644 --- a/examples/tutorials/00_sync/060_harness_openai/manifest.yaml +++ b/examples/tutorials/00_sync/050_openai_agents/manifest.yaml @@ -2,10 +2,10 @@ build: context: root: ../../ include_paths: - - 00_sync/060_harness_openai + - 00_sync/050_openai_agents - test_utils - dockerfile: 00_sync/060_harness_openai/Dockerfile - dockerignore: 00_sync/060_harness_openai/.dockerignore + dockerfile: 00_sync/050_openai_agents/Dockerfile + dockerignore: 00_sync/050_openai_agents/.dockerignore local_development: agent: @@ -16,7 +16,7 @@ local_development: agent: acp_type: sync - name: s060-harness-openai + name: s050-openai-agents description: A sync OpenAI Agents SDK agent on the unified harness surface temporal: @@ -46,7 +46,7 @@ deployment: global: agent: - name: "s060-harness-openai" + name: "s050-openai-agents" description: "A sync OpenAI Agents SDK agent on the unified harness surface" replicaCount: 1 resources: diff --git a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/__init__.py b/examples/tutorials/00_sync/050_openai_agents/project/__init__.py similarity index 100% rename from examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/__init__.py rename to examples/tutorials/00_sync/050_openai_agents/project/__init__.py diff --git a/examples/tutorials/00_sync/060_harness_openai/project/acp.py b/examples/tutorials/00_sync/050_openai_agents/project/acp.py similarity index 100% rename from examples/tutorials/00_sync/060_harness_openai/project/acp.py rename to examples/tutorials/00_sync/050_openai_agents/project/acp.py diff --git a/examples/tutorials/00_sync/060_harness_openai/project/agent.py b/examples/tutorials/00_sync/050_openai_agents/project/agent.py similarity index 100% rename from examples/tutorials/00_sync/060_harness_openai/project/agent.py rename to examples/tutorials/00_sync/050_openai_agents/project/agent.py diff --git a/examples/tutorials/00_sync/060_harness_openai/project/tools.py b/examples/tutorials/00_sync/050_openai_agents/project/tools.py similarity index 100% rename from examples/tutorials/00_sync/060_harness_openai/project/tools.py rename to examples/tutorials/00_sync/050_openai_agents/project/tools.py diff --git a/examples/tutorials/00_sync/060_harness_openai/pyproject.toml b/examples/tutorials/00_sync/050_openai_agents/pyproject.toml similarity index 95% rename from examples/tutorials/00_sync/060_harness_openai/pyproject.toml rename to examples/tutorials/00_sync/050_openai_agents/pyproject.toml index 39cceb8f2..48d2481dd 100644 --- a/examples/tutorials/00_sync/060_harness_openai/pyproject.toml +++ b/examples/tutorials/00_sync/050_openai_agents/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "s060-harness-openai" +name = "s050-openai-agents" version = "0.1.0" description = "A sync OpenAI Agents SDK agent on the unified harness surface" readme = "README.md" diff --git a/examples/tutorials/00_sync/060_harness_openai/tests/test_agent.py b/examples/tutorials/00_sync/050_openai_agents/tests/test_agent.py similarity index 100% rename from examples/tutorials/00_sync/060_harness_openai/tests/test_agent.py rename to examples/tutorials/00_sync/050_openai_agents/tests/test_agent.py diff --git a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/Dockerfile b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/Dockerfile deleted file mode 100644 index 8e0ec22df..000000000 --- a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/Dockerfile +++ /dev/null @@ -1,50 +0,0 @@ -# syntax=docker/dockerfile:1.3 -FROM python:3.12-slim -COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - htop \ - vim \ - curl \ - tar \ - python3-dev \ - postgresql-client \ - build-essential \ - libpq-dev \ - gcc \ - cmake \ - netcat-openbsd \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -RUN uv pip install --system --upgrade pip setuptools wheel - -ENV UV_HTTP_TIMEOUT=1000 - -# Copy pyproject.toml and README.md to install dependencies -COPY 00_sync/050_openai_agents_local_sandbox/pyproject.toml /app/050_openai_agents_local_sandbox/pyproject.toml -COPY 00_sync/050_openai_agents_local_sandbox/README.md /app/050_openai_agents_local_sandbox/README.md - -WORKDIR /app/050_openai_agents_local_sandbox - -# Copy the project code -COPY 00_sync/050_openai_agents_local_sandbox/project /app/050_openai_agents_local_sandbox/project - -# Copy the test files -COPY 00_sync/050_openai_agents_local_sandbox/tests /app/050_openai_agents_local_sandbox/tests - -# Copy shared test utilities -COPY test_utils /app/test_utils - -# Install the required Python packages with dev dependencies -RUN uv pip install --system .[dev] - -# Set environment variables -ENV PYTHONPATH=/app - -# Set test environment variables -ENV AGENT_NAME=s050-openai-agents-local-sandbox - -# Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/README.md b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/README.md deleted file mode 100644 index 9c2c81d7d..000000000 --- a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/README.md +++ /dev/null @@ -1,113 +0,0 @@ -# Tutorial 050: Sync OpenAI Agents SDK with a Local Sandbox - -This tutorial demonstrates how to build a **synchronous** agent on AgentEx using the -[OpenAI Agents SDK](https://developers.openai.com/api/docs/guides/agents) and its -**sandbox** runtime, running with the **local** (`unix_local`) backend. - -The agent is a "local sandbox assistant": it answers questions by actually running -real shell commands (e.g. `python3 --version`, `ls /tmp`, `python3 -c "..."`) -instead of guessing. - -## Key Concepts - -### Sync ACP -The sync ACP model uses HTTP request/response for communication. The -`@acp.on_message_send` handler receives a message, runs the agent, and returns the -agent's final answer as a `TextContent`. - -### OpenAI Agents SDK Sandbox -The OpenAI Agents SDK ships `agents.sandbox`, which lets you give an agent -**capabilities** (instead of hand-written tools) that the runtime turns into real -tools backed by a sandbox: - -- **`SandboxAgent`**: an `Agent` that is granted sandbox capabilities. -- **Capabilities** (`from agents.sandbox.capabilities import Shell, Filesystem, Memory`): - each capability expands into a set of real tools. This tutorial uses `Shell`, which - lets the model run real shell commands. -- **`SandboxRunConfig`** + a sandbox **client**: tells the runtime *where* the tools - actually execute. - -### The LOCAL sandbox (`UnixLocalSandboxClient`) -This tutorial uses the local backend -(`from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient, UnixLocalSandboxClientOptions`), -`backend_id="unix_local"`. The local sandbox runs shell commands **ON THE HOST** — -the agent's own container/process. There is **no Docker, no Temporal, and no remote -sandbox infrastructure** involved. This makes it the simplest way to give an agent a -real shell. - -The sandbox is wired up through the SDK's `RunConfig`: - -```python -from agents import Runner, set_tracing_disabled -from agents.run_config import RunConfig -from agents.sandbox import SandboxAgent, SandboxRunConfig -from agents.sandbox.capabilities import Shell -from agents.sandbox.sandboxes.unix_local import ( - UnixLocalSandboxClient, - UnixLocalSandboxClientOptions, -) - -set_tracing_disabled(True) # avoid api.openai.com tracing 401 behind a gateway - -agent = SandboxAgent( - name="Local Sandbox Assistant", - instructions="...use the shell tools to actually run commands...", - capabilities=[Shell()], -) -run_config = RunConfig( - sandbox=SandboxRunConfig( - client=UnixLocalSandboxClient(), - options=UnixLocalSandboxClientOptions(), - ) -) -result = await Runner.run(agent, input="what's the python version?", run_config=run_config) -print(result.final_output) -``` - -`Runner.run` drives the full tool-call loop internally: the model issues shell -commands, the local sandbox runs them on the host, the output is fed back, and the -loop continues until the model produces a final answer. - -## Files - -| File | Description | -|------|-------------| -| `project/acp.py` | ACP server and message handler (runs the sandbox agent) | -| `project/agent.py` | `SandboxAgent` + `RunConfig(sandbox=...)` wiring + `run_agent` | -| `project/tools.py` | Sandbox capability factory (`Shell`) | -| `tests/test_agent.py` | Integration tests | -| `manifest.yaml` | Agent configuration | - -## Running Locally - -```bash -# From this directory -agentex agents run -``` - -Set `OPENAI_API_KEY` (or `LITELLM_API_KEY` if you're behind the Scale LiteLLM -gateway) in your environment or in a `.env` file in `project/` so the agent can call -the model. - -## Running Tests - -```bash -pytest tests/test_agent.py -v -``` - -## Notes - -- **No infra required.** Because this uses the `unix_local` backend, the shell tools - run directly in the agent's process — no Docker daemon, no Temporal, no remote - sandbox. Swap the client for a remote/containerized backend to isolate execution. -- **Tracing.** `set_tracing_disabled(True)` turns off the OpenAI Agents SDK's native - tracer (which would otherwise try to ship traces to `api.openai.com`). The manifest - also sets `OPENAI_AGENTS_DISABLE_TRACING=1`. AgentEx/SGP tracing still runs via the - tracing manager configured in `acp.py` when SGP credentials are present. -- **Capabilities are the tools.** To let the agent do more, add capabilities in - `project/tools.py` (e.g. `Filesystem()`, `Memory()`). - -## Further Reading - -- OpenAI Agents SDK guide: https://developers.openai.com/api/docs/guides/agents -- The next evolution of the Agents SDK: https://openai.com/index/the-next-evolution-of-the-agents-sdk/ diff --git a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/manifest.yaml b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/manifest.yaml deleted file mode 100644 index 8ae5b98a1..000000000 --- a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/manifest.yaml +++ /dev/null @@ -1,61 +0,0 @@ -build: - context: - root: ../../ - include_paths: - - 00_sync/050_openai_agents_local_sandbox - - test_utils - dockerfile: 00_sync/050_openai_agents_local_sandbox/Dockerfile - dockerignore: 00_sync/050_openai_agents_local_sandbox/.dockerignore - -local_development: - agent: - port: 8000 - host_address: host.docker.internal - paths: - acp: project/acp.py - -agent: - acp_type: sync - name: s050-openai-agents-local-sandbox - description: A sync OpenAI Agents SDK agent using a local (unix_local) sandbox - - temporal: - enabled: false - - credentials: - - env_var_name: OPENAI_API_KEY - secret_name: openai-api-key - secret_key: api-key - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - - env_var_name: SGP_API_KEY - secret_name: sgp-api-key - secret_key: api-key - - env_var_name: SGP_ACCOUNT_ID - secret_name: sgp-account-id - secret_key: account-id - - env_var_name: SGP_CLIENT_BASE_URL - secret_name: sgp-client-base-url - secret_key: url - - env: - OPENAI_AGENTS_DISABLE_TRACING: "1" - -deployment: - image: - repository: "" - tag: "latest" - - global: - agent: - name: "s050-openai-agents-local-sandbox" - description: "A sync OpenAI Agents SDK agent using a local (unix_local) sandbox" - replicaCount: 1 - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" diff --git a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/acp.py b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/acp.py deleted file mode 100644 index 005d679bf..000000000 --- a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/acp.py +++ /dev/null @@ -1,77 +0,0 @@ -"""ACP (Agent Communication Protocol) handler for Agentex. - -This is the API layer — it owns the agent lifecycle and runs the OpenAI Agents -SDK *sandbox* agent for each incoming message, returning the agent's final -answer to the Agentex frontend. - -The agent uses the LOCAL sandbox backend (``UnixLocalSandboxClient``), which runs -shell commands on the host (this process/container). The OpenAI Agents SDK runs -its tool-call loop internally via ``Runner.run`` and returns the final output, so -this sync handler returns a single ``TextContent`` rather than streaming tokens. -""" - -from __future__ import annotations - -import os - -from dotenv import load_dotenv - -load_dotenv() - -from agentex.lib import adk -from project.agent import run_agent -from agentex.lib.types.acp import SendMessageParams -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.types.text_content import TextContent -from agentex.lib.sdk.fastacp.fastacp import FastACP -from agentex.types.task_message_content import TaskMessageContent -from agentex.lib.core.tracing.tracing_processor_manager import ( - add_tracing_processor_config, -) - -logger = make_logger(__name__) - -# LiteLLM proxy auth: copy LITELLM_API_KEY to OPENAI_API_KEY for OpenAI client -# compatibility, so the same example works behind the Scale LiteLLM gateway. -_litellm_key = os.environ.get("LITELLM_API_KEY") -if _litellm_key and not os.environ.get("OPENAI_API_KEY"): - os.environ["OPENAI_API_KEY"] = _litellm_key - -SGP_API_KEY = os.environ.get("SGP_API_KEY", "") -SGP_ACCOUNT_ID = os.environ.get("SGP_ACCOUNT_ID", "") -SGP_CLIENT_BASE_URL = os.environ.get("SGP_CLIENT_BASE_URL", "") - -if SGP_API_KEY and SGP_ACCOUNT_ID: - add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=SGP_API_KEY, - sgp_account_id=SGP_ACCOUNT_ID, - sgp_base_url=SGP_CLIENT_BASE_URL, - ) - ) - -acp = FastACP.create(acp_type="sync") - - -@acp.on_message_send -async def handle_message_send( - params: SendMessageParams, -) -> TaskMessageContent: - """Handle incoming messages by running the local-sandbox agent.""" - task_id = params.task.id - user_message = params.content.content - logger.info(f"Processing message for task {task_id}") - - async with adk.tracing.span( - trace_id=task_id, - task_id=task_id, - name="message", - input={"message": user_message}, - data={"__span_type__": "AGENT_WORKFLOW"}, - ) as turn_span: - final_output = await run_agent(user_message) - if turn_span: - turn_span.output = {"final_output": final_output} - - return TextContent(author="agent", content=final_output) diff --git a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/agent.py b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/agent.py deleted file mode 100644 index d674d14c9..000000000 --- a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/agent.py +++ /dev/null @@ -1,92 +0,0 @@ -"""OpenAI Agents SDK local-sandbox agent definition. - -This mirrors the Pydantic AI tutorial (040): the agent is the boundary between -this module and the API layer (acp.py). The difference is the runtime — here we -use the OpenAI Agents SDK ``SandboxAgent`` together with the **local** sandbox -backend (``UnixLocalSandboxClient``). - -The local sandbox runs shell commands ON THE HOST — the agent's own -container/process. There is no Docker, no Temporal, and no remote sandbox -infrastructure. The OpenAI Agents SDK runs its own tool-call loop internally: -when the model decides to run a shell command, the sandbox executes it locally -and feeds the output back to the model until it produces a final answer. -""" - -from __future__ import annotations - -from datetime import datetime - -from agents import Runner, set_tracing_disabled -from agents.sandbox import SandboxAgent, SandboxRunConfig -from agents.run_config import RunConfig -from agents.sandbox.sandboxes.unix_local import ( - UnixLocalSandboxClient, - UnixLocalSandboxClientOptions, -) - -from project.tools import get_capabilities - -# Disable the openai-agents SDK's native tracer so it doesn't ship traces to -# api.openai.com using OPENAI_API_KEY (which may be a gateway/proxy key and would -# 401). Agentex tracing still runs via the tracing manager configured in acp.py. -set_tracing_disabled(True) - -MODEL_NAME = "gpt-4o-mini" -INSTRUCTIONS = """You are a local sandbox assistant. - -Current date and time: {timestamp} - -You have access to shell tools that run real commands on the local machine. - -Guidelines: -- ALWAYS use the shell tools to actually run commands — never guess or make up - output. If the user asks for the Python version, run `python3 --version`. If - they ask to list files, run `ls`. If they ask you to compute something, use - `python3 -c "..."`. -- Run the minimal command(s) needed to answer the question. -- Report the real command output back to the user, concisely. -""" - - -def create_agent() -> SandboxAgent: - """Build and return the OpenAI Agents SDK sandbox agent. - - The agent is granted shell capabilities (see ``project.tools``). The actual - sandbox backend (where the shell commands run) is supplied at run time via - the ``RunConfig`` returned by ``create_run_config``. - """ - return SandboxAgent( - name="Local Sandbox Assistant", - model=MODEL_NAME, - instructions=INSTRUCTIONS.format( - timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") - ), - capabilities=get_capabilities(), - ) - - -def create_run_config() -> RunConfig: - """Build the RunConfig that points the agent at the LOCAL sandbox backend. - - ``UnixLocalSandboxClient`` (backend_id="unix_local") runs shell commands on - the host — the agent's own process — so no Docker or remote infra is needed. - """ - return RunConfig( - sandbox=SandboxRunConfig( - client=UnixLocalSandboxClient(), - options=UnixLocalSandboxClientOptions(), - ) - ) - - -async def run_agent(user_message: str) -> str: - """Run the sandbox agent on a single user message and return the final text. - - The OpenAI Agents SDK handles the full tool-call loop internally: the model - issues shell commands, the local sandbox runs them on the host, and the - output is fed back until the model produces a final answer. - """ - agent = create_agent() - run_config = create_run_config() - result = await Runner.run(agent, input=user_message, run_config=run_config, max_turns=10) - return result.final_output diff --git a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/tools.py b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/tools.py deleted file mode 100644 index 0ad8f25ac..000000000 --- a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/tools.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Sandbox capabilities for the OpenAI Agents SDK local-sandbox agent. - -Unlike the Pydantic AI tutorial (040), this agent does not register hand-written -Python functions as tools. Instead it is given *capabilities* — the OpenAI Agents -SDK sandbox runtime turns each capability into a real set of tools (run a shell -command, read a file, etc.) backed by an actual sandbox backend. - -Here we use the ``Shell`` capability, which lets the model run real shell commands. -With the local (``unix_local``) backend those commands execute ON THE HOST — the -agent's own process/container — so there is no Docker, Temporal, or remote infra -involved. This module hosts the capability factory so the agent wiring in -``project.agent`` stays readable and the capability set is easy to extend -(e.g. add ``Filesystem()`` or ``Memory()``). -""" - -from __future__ import annotations - -from agents.sandbox.capabilities import Shell - - -def get_capabilities() -> list: - """Return the sandbox capabilities the agent is allowed to use. - - Returns: - A list of OpenAI Agents SDK sandbox capabilities. We grant ``Shell`` so - the agent can run real shell commands on the local machine. Add - ``Filesystem()`` or ``Memory()`` here to expand what the agent can do. - """ - return [Shell()] diff --git a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/pyproject.toml b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/pyproject.toml deleted file mode 100644 index 472a6bef7..000000000 --- a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/pyproject.toml +++ /dev/null @@ -1,36 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "s050-openai-agents-local-sandbox" -version = "0.1.0" -description = "A sync OpenAI Agents SDK agent using a local (unix_local) sandbox" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "agentex-sdk", - "scale-gp", - "openai-agents>=0.14.3,<0.15", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "httpx", - "black", - "isort", - "flake8", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] - -[tool.black] -line-length = 88 -target-version = ['py312'] - -[tool.isort] -profile = "black" -line_length = 88 diff --git a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/tests/test_agent.py b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/tests/test_agent.py deleted file mode 100644 index 52ed1bf2f..000000000 --- a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/tests/test_agent.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Tests for the sync OpenAI Agents SDK local-sandbox agent. - -This test suite validates: -- Sending a message that requires the agent to actually run a shell command in - the LOCAL sandbox (unix_local backend) and receiving a non-empty response. - -To run these tests: -1. Make sure the agent is running (via docker-compose or `agentex agents run`) -2. Set the AGENTEX_API_BASE_URL environment variable if not using default -3. Run: pytest test_agent.py -v - -Configuration: -- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) -- AGENT_NAME: Name of the agent to test (default: s050-openai-agents-local-sandbox) -""" - -import os - -import pytest -from test_utils.sync import validate_text_in_string - -from agentex import Agentex -from agentex.types import TextContentParam -from agentex.types.agent_rpc_params import ParamsSendMessageRequest - -AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") -AGENT_NAME = os.environ.get("AGENT_NAME", "s050-openai-agents-local-sandbox") - - -@pytest.fixture -def client(): - """Create an AgentEx client instance for testing.""" - return Agentex(base_url=AGENTEX_API_BASE_URL) - - -@pytest.fixture -def agent_name(): - """Return the agent name for testing.""" - return AGENT_NAME - - -@pytest.fixture -def agent_id(client, agent_name): - """Retrieve the agent ID based on the agent name.""" - agents = client.agents.list() - for agent in agents: - if agent.name == agent_name: - return agent.id - raise ValueError(f"Agent with name {agent_name} not found.") - - -def _response_text(result) -> str: - """Flatten a send_message result into a single string for assertions. - - Result items may be a bare string, a ``TextContent`` (``.content`` is the - string), or a ``TaskMessage`` wrapping a ``TextContent`` (``.content`` is the - ``TextContent``, whose ``.content`` is the string). Dig through ``.content`` - until we reach a string. - """ - - def _text_of(obj, _depth: int = 0) -> str: - if isinstance(obj, str): - return obj - if _depth > 5: - return "" - inner = getattr(obj, "content", None) - if inner is None: - return "" - return _text_of(inner, _depth + 1) - - parts = [t for t in (_text_of(item) for item in result) if t] - return "\n".join(parts) - - -class TestLocalSandboxMessages: - """Test the local-sandbox OpenAI Agents SDK agent.""" - - def test_send_simple_message(self, client: Agentex, agent_name: str): - """Test sending a simple message and receiving a response.""" - response = client.agents.send_message( - agent_name=agent_name, - params=ParamsSendMessageRequest( - content=TextContentParam( - author="user", - content="Hello! What can you help me with?", - type="text", - ) - ), - ) - result = response.result - assert result is not None - assert len(result) >= 1 - - def test_shell_python_version(self, client: Agentex, agent_name: str): - """Test that the agent uses its shell to run a real command. - - We ask it to print the Python version. The agent should run - `python3 --version` in the local sandbox and report the real output, - which always starts with "Python 3". - """ - response = client.agents.send_message( - agent_name=agent_name, - params=ParamsSendMessageRequest( - content=TextContentParam( - author="user", - content=( - "Use your shell to print the Python version on this " - "machine, then tell me what it is." - ), - type="text", - ) - ), - ) - result = response.result - assert result is not None - assert len(result) >= 1 - - text = _response_text(result) - assert text, "Expected a non-empty response from the sandbox agent." - # The sandbox runs on Python 3.12, so the real output contains "Python 3". - validate_text_in_string("Python 3", text) - - def test_shell_compute(self, client: Agentex, agent_name: str): - """Test that the agent uses python3 in the sandbox to compute a value.""" - response = client.agents.send_message( - agent_name=agent_name, - params=ParamsSendMessageRequest( - content=TextContentParam( - author="user", - content=( - "Use python3 in your shell to compute 21 * 2 and tell me " - "the result." - ), - type="text", - ) - ), - ) - result = response.result - assert result is not None - assert len(result) >= 1 - - text = _response_text(result) - assert text, "Expected a non-empty response from the sandbox agent." - validate_text_in_string("42", text) - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/00_sync/060_harness_openai/Dockerfile b/examples/tutorials/00_sync/060_harness_openai/Dockerfile deleted file mode 100644 index 1bd4f4860..000000000 --- a/examples/tutorials/00_sync/060_harness_openai/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/060_harness_openai/pyproject.toml /app/060_harness_openai/pyproject.toml -COPY 00_sync/060_harness_openai/README.md /app/060_harness_openai/README.md - -WORKDIR /app/060_harness_openai - -# Copy the project code -COPY 00_sync/060_harness_openai/project /app/060_harness_openai/project - -# Copy the test files -COPY 00_sync/060_harness_openai/tests /app/060_harness_openai/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=s060-harness-openai - -# Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/00_sync/060_harness_openai/.dockerignore b/examples/tutorials/00_sync/070_codex/.dockerignore similarity index 100% rename from examples/tutorials/00_sync/060_harness_openai/.dockerignore rename to examples/tutorials/00_sync/070_codex/.dockerignore diff --git a/examples/tutorials/00_sync/harness_codex/Dockerfile b/examples/tutorials/00_sync/070_codex/Dockerfile similarity index 74% rename from examples/tutorials/00_sync/harness_codex/Dockerfile rename to examples/tutorials/00_sync/070_codex/Dockerfile index 72713b95d..75abf677d 100644 --- a/examples/tutorials/00_sync/harness_codex/Dockerfile +++ b/examples/tutorials/00_sync/070_codex/Dockerfile @@ -23,16 +23,16 @@ 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/harness_codex/pyproject.toml /app/harness_codex/pyproject.toml -COPY 00_sync/harness_codex/README.md /app/harness_codex/README.md +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/harness_codex +WORKDIR /app/070_codex # Copy the project code -COPY 00_sync/harness_codex/project /app/harness_codex/project +COPY 00_sync/070_codex/project /app/070_codex/project # Copy the test files -COPY 00_sync/harness_codex/tests /app/harness_codex/tests +COPY 00_sync/070_codex/tests /app/070_codex/tests # Copy shared test utilities COPY test_utils /app/test_utils @@ -44,7 +44,7 @@ RUN uv pip install --system .[dev] ENV PYTHONPATH=/app # Set test environment variables -ENV AGENT_NAME=s-harness-codex +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/harness_codex/README.md b/examples/tutorials/00_sync/070_codex/README.md similarity index 95% rename from examples/tutorials/00_sync/harness_codex/README.md rename to examples/tutorials/00_sync/070_codex/README.md index 5f3396cfa..3abb2766f 100644 --- a/examples/tutorials/00_sync/harness_codex/README.md +++ b/examples/tutorials/00_sync/070_codex/README.md @@ -1,4 +1,4 @@ -# harness_codex (sync) +# 070_codex (sync) Tutorial agent demonstrating the `convert_codex_to_agentex_events` tap, `CodexTurn`, and `UnifiedEmitter` for a **sync** (HTTP-yield) ACP agent. @@ -27,7 +27,7 @@ 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/harness_codex/tests/test_agent.py -q +uv run --all-packages --all-extras pytest examples/tutorials/00_sync/070_codex/tests/test_agent.py -q ``` ## Running live integration tests diff --git a/examples/tutorials/00_sync/harness_codex/conftest.py b/examples/tutorials/00_sync/070_codex/conftest.py similarity index 100% rename from examples/tutorials/00_sync/harness_codex/conftest.py rename to examples/tutorials/00_sync/070_codex/conftest.py diff --git a/examples/tutorials/00_sync/harness_codex/manifest.yaml b/examples/tutorials/00_sync/070_codex/manifest.yaml similarity index 86% rename from examples/tutorials/00_sync/harness_codex/manifest.yaml rename to examples/tutorials/00_sync/070_codex/manifest.yaml index 52943f8f2..87dad2847 100644 --- a/examples/tutorials/00_sync/harness_codex/manifest.yaml +++ b/examples/tutorials/00_sync/070_codex/manifest.yaml @@ -2,10 +2,10 @@ build: context: root: ../../ include_paths: - - 00_sync/harness_codex + - 00_sync/070_codex - test_utils - dockerfile: 00_sync/harness_codex/Dockerfile - dockerignore: 00_sync/harness_codex/.dockerignore + dockerfile: 00_sync/070_codex/Dockerfile + dockerignore: 00_sync/070_codex/.dockerignore local_development: agent: @@ -16,7 +16,7 @@ local_development: agent: acp_type: sync - name: s-harness-codex + name: s070-codex description: Sync tutorial agent driving the unified harness surface via local codex CLI subprocess temporal: @@ -46,7 +46,7 @@ deployment: global: agent: - name: "s-harness-codex" + name: "s070-codex" description: "Sync tutorial agent driving the unified harness surface via local codex CLI subprocess" replicaCount: 1 resources: diff --git a/examples/tutorials/00_sync/060_harness_openai/project/__init__.py b/examples/tutorials/00_sync/070_codex/project/__init__.py similarity index 100% rename from examples/tutorials/00_sync/060_harness_openai/project/__init__.py rename to examples/tutorials/00_sync/070_codex/project/__init__.py diff --git a/examples/tutorials/00_sync/harness_codex/project/acp.py b/examples/tutorials/00_sync/070_codex/project/acp.py similarity index 100% rename from examples/tutorials/00_sync/harness_codex/project/acp.py rename to examples/tutorials/00_sync/070_codex/project/acp.py diff --git a/examples/tutorials/00_sync/harness_codex/pyproject.toml b/examples/tutorials/00_sync/070_codex/pyproject.toml similarity index 96% rename from examples/tutorials/00_sync/harness_codex/pyproject.toml rename to examples/tutorials/00_sync/070_codex/pyproject.toml index ca7d8ac18..88bbb9cca 100644 --- a/examples/tutorials/00_sync/harness_codex/pyproject.toml +++ b/examples/tutorials/00_sync/070_codex/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "s-harness-codex" +name = "s070-codex" version = "0.1.0" description = "Sync tutorial agent driving the unified harness surface via local codex CLI subprocess" readme = "README.md" diff --git a/examples/tutorials/00_sync/harness_codex/tests/test_agent.py b/examples/tutorials/00_sync/070_codex/tests/test_agent.py similarity index 99% rename from examples/tutorials/00_sync/harness_codex/tests/test_agent.py rename to examples/tutorials/00_sync/070_codex/tests/test_agent.py index b2d5b6498..94aa2aaf2 100644 --- a/examples/tutorials/00_sync/harness_codex/tests/test_agent.py +++ b/examples/tutorials/00_sync/070_codex/tests/test_agent.py @@ -145,7 +145,7 @@ async def test_on_result_callback_receives_session_id(self): 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", "s-harness-codex") +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") diff --git a/examples/tutorials/00_sync/harness_langgraph/README.md b/examples/tutorials/00_sync/harness_langgraph/README.md deleted file mode 100644 index 86367f162..000000000 --- a/examples/tutorials/00_sync/harness_langgraph/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# Tutorial: Sync Harness 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 -``` - -Compare with ``030_langgraph``, which uses the bespoke -``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 (identical to 030_langgraph) | -| `project/tools.py` | Tool definitions (weather example) | -| `tests/test_agent.py` | Integration tests | -| `manifest.yaml` | Agent configuration (name: s-harness-langgraph) | - -## Running Locally - -```bash -agentex agents run -``` - -## Running Tests - -```bash -pytest tests/test_agent.py -v -``` diff --git a/examples/tutorials/00_sync/harness_langgraph/manifest.yaml b/examples/tutorials/00_sync/harness_langgraph/manifest.yaml deleted file mode 100644 index 1f57678f2..000000000 --- a/examples/tutorials/00_sync/harness_langgraph/manifest.yaml +++ /dev/null @@ -1,58 +0,0 @@ -build: - context: - root: ../../ - include_paths: - - 00_sync/harness_langgraph - - test_utils - dockerfile: 00_sync/harness_langgraph/Dockerfile - dockerignore: 00_sync/harness_langgraph/.dockerignore - -local_development: - agent: - port: 8000 - host_address: host.docker.internal - paths: - acp: project/acp.py - -agent: - acp_type: sync - name: s-harness-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: "s-harness-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/harness_langgraph/project/acp.py b/examples/tutorials/00_sync/harness_langgraph/project/acp.py deleted file mode 100644 index f609f1682..000000000 --- a/examples/tutorials/00_sync/harness_langgraph/project/acp.py +++ /dev/null @@ -1,107 +0,0 @@ -"""ACP handler for sync harness 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. - -Differences from ``030_langgraph`` (bespoke path): -- No ``create_langgraph_tracing_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/harness_langgraph/project/graph.py b/examples/tutorials/00_sync/harness_langgraph/project/graph.py deleted file mode 100644 index 4516087d2..000000000 --- a/examples/tutorials/00_sync/harness_langgraph/project/graph.py +++ /dev/null @@ -1,67 +0,0 @@ -"""LangGraph graph definition for the harness_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/harness_langgraph/project/tools.py b/examples/tutorials/00_sync/harness_langgraph/project/tools.py deleted file mode 100644 index f02587430..000000000 --- a/examples/tutorials/00_sync/harness_langgraph/project/tools.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Tool definitions for the harness_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/harness_langgraph/pyproject.toml b/examples/tutorials/00_sync/harness_langgraph/pyproject.toml deleted file mode 100644 index deecd08b3..000000000 --- a/examples/tutorials/00_sync/harness_langgraph/pyproject.toml +++ /dev/null @@ -1,37 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "s-harness-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/harness_langgraph/tests/test_agent.py b/examples/tutorials/00_sync/harness_langgraph/tests/test_agent.py deleted file mode 100644 index 2eb561cec..000000000 --- a/examples/tutorials/00_sync/harness_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: s-harness-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", "s-harness-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/harness_pydantic_ai/Dockerfile b/examples/tutorials/00_sync/harness_pydantic_ai/Dockerfile deleted file mode 100644 index 3a9412fa9..000000000 --- a/examples/tutorials/00_sync/harness_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/harness_pydantic_ai/pyproject.toml /app/harness_pydantic_ai/pyproject.toml -COPY 00_sync/harness_pydantic_ai/README.md /app/harness_pydantic_ai/README.md - -WORKDIR /app/harness_pydantic_ai - -# Copy the project code -COPY 00_sync/harness_pydantic_ai/project /app/harness_pydantic_ai/project - -# Copy the test files -COPY 00_sync/harness_pydantic_ai/tests /app/harness_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=s-harness-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/harness_pydantic_ai/README.md b/examples/tutorials/00_sync/harness_pydantic_ai/README.md deleted file mode 100644 index 1466bc4e7..000000000 --- a/examples/tutorials/00_sync/harness_pydantic_ai/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# Sync Pydantic AI Harness Test 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 - -The `00_sync/040_pydantic_ai` tutorial streams via the bare -`convert_pydantic_ai_to_agentex_events` converter and does **not** exercise the -unified `yield_turn` path. This harness test agent is the sync coverage for the -unified surface: it proves an agent author can wire the sync channel through -`UnifiedEmitter` and get 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 at -`tests/lib/core/harness/test_harness_pydantic_ai_sync.py`. diff --git a/examples/tutorials/00_sync/harness_pydantic_ai/manifest.yaml b/examples/tutorials/00_sync/harness_pydantic_ai/manifest.yaml deleted file mode 100644 index 55d8f5d2b..000000000 --- a/examples/tutorials/00_sync/harness_pydantic_ai/manifest.yaml +++ /dev/null @@ -1,58 +0,0 @@ -build: - context: - root: ../../ - include_paths: - - 00_sync/harness_pydantic_ai - - test_utils - dockerfile: 00_sync/harness_pydantic_ai/Dockerfile - dockerignore: 00_sync/harness_pydantic_ai/.dockerignore - -local_development: - agent: - port: 8000 - host_address: host.docker.internal - paths: - acp: project/acp.py - -agent: - acp_type: sync - name: s-harness-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: "s-harness-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/harness_pydantic_ai/project/acp.py b/examples/tutorials/00_sync/harness_pydantic_ai/project/acp.py deleted file mode 100644 index f23cd7960..000000000 --- a/examples/tutorials/00_sync/harness_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/harness_pydantic_ai/project/agent.py b/examples/tutorials/00_sync/harness_pydantic_ai/project/agent.py deleted file mode 100644 index 72fd74173..000000000 --- a/examples/tutorials/00_sync/harness_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/harness_pydantic_ai/project/tools.py b/examples/tutorials/00_sync/harness_pydantic_ai/project/tools.py deleted file mode 100644 index d649c75f1..000000000 --- a/examples/tutorials/00_sync/harness_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/harness_pydantic_ai/pyproject.toml b/examples/tutorials/00_sync/harness_pydantic_ai/pyproject.toml deleted file mode 100644 index 08f709a4a..000000000 --- a/examples/tutorials/00_sync/harness_pydantic_ai/pyproject.toml +++ /dev/null @@ -1,36 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "s-harness-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/harness_pydantic_ai/tests/test_agent.py b/examples/tutorials/00_sync/harness_pydantic_ai/tests/test_agent.py deleted file mode 100644 index 96da95fdc..000000000 --- a/examples/tutorials/00_sync/harness_pydantic_ai/tests/test_agent.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Live tests for the sync harness 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. They mirror the -``040_pydantic_ai`` tutorial tests but target this harness agent. - -Offline coverage of the same wiring (TestModel + fake streaming/tracing) lives -in ``tests/lib/core/harness/test_harness_pydantic_ai_sync.py`` in the SDK repo. - -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: s-harness-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", "s-harness-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/10_async/00_base/100_langgraph/README.md b/examples/tutorials/10_async/00_base/100_langgraph/README.md index 6f6c6a36b..cd2fa6dd6 100644 --- a/examples/tutorials/10_async/00_base/100_langgraph/README.md +++ b/examples/tutorials/10_async/00_base/100_langgraph/README.md @@ -1,46 +1,52 @@ -# Tutorial 100: Async LangGraph Agent +# Tutorial: Async LangGraph Agent -This tutorial demonstrates how to build an **asynchronous** LangGraph agent on AgentEx with: -- Task-based event handling via Redis -- Tool calling (ReAct pattern) -- Multi-turn conversation memory via AgentEx checkpointer -- Tracing integration +This tutorial demonstrates how to build an **async** LangGraph agent on AgentEx +using the **unified harness surface**: -## Graph Structure +```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`). -![Graph](graph.png) +`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=...)`. -## Sync vs Async: Key Differences +The same `LangGraphTurn` object can also be passed to +`UnifiedEmitter.yield_turn` in the sync channel. -| Aspect | Sync (Tutorial 030) | Async (This Tutorial) | -|--------|--------------------|-----------------------| -| **ACP Type** | `sync` | `async` | -| **Handler** | `@acp.on_message_send` | `@acp.on_task_event_send` | -| **Response** | HTTP streaming (yields) | Redis streaming | -| **Message Echo** | Implicit | Explicit (`adk.messages.create`) | -| **Streaming Helper** | `convert_langgraph_to_agentex_events()` | `stream_langgraph_events()` | -| **Extra Handlers** | None | `on_task_create`, `on_task_cancel` | +### AGX1-377 Note -### 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 +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 with async event handlers | -| `project/graph.py` | LangGraph state graph definition | +| `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 | +| `manifest.yaml` | Agent configuration (name: ab100-langgraph) | ## Running Locally ```bash -# From this directory agentex agents run ``` diff --git a/examples/tutorials/10_async/00_base/100_langgraph/graph.png b/examples/tutorials/10_async/00_base/100_langgraph/graph.png deleted file mode 100644 index 16d22a1e7ec819b0f0520a1347c729ca6adcbe5a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16357 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 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") - ) + 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]} 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 index 1b402a906..e421528fc 100644 --- a/examples/tutorials/10_async/00_base/100_langgraph/project/tools.py +++ b/examples/tutorials/10_async/00_base/100_langgraph/project/tools.py @@ -1,9 +1,4 @@ -""" -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. -""" +"""Tool definitions for the 100_langgraph async agent.""" from langchain_core.tools import Tool @@ -17,16 +12,13 @@ def get_weather(city: str) -> str: Returns: A string describing the weather conditions. """ - # TODO: Replace with actual weather API call return f"The weather in {city} is sunny and 72°F" -# Define tools weather_tool = Tool( name="get_weather", func=get_weather, description="Get the current weather for a city. Input should be a city name.", ) -# Export all tools as a list TOOLS = [weather_tool] diff --git a/examples/tutorials/10_async/00_base/100_langgraph/pyproject.toml b/examples/tutorials/10_async/00_base/100_langgraph/pyproject.toml index fecbc6149..715477bac 100644 --- a/examples/tutorials/10_async/00_base/100_langgraph/pyproject.toml +++ b/examples/tutorials/10_async/00_base/100_langgraph/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "ab100-langgraph" version = "0.1.0" -description = "An async LangGraph agent with tool calling and Redis streaming" +description = "An async LangGraph agent using the unified harness surface" readme = "README.md" requires-python = ">=3.12" dependencies = [ 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 index 948db1558..b80d7a8f9 100644 --- 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 @@ -1,14 +1,8 @@ """ -Tests for the async LangGraph agent. +Tests for the async harness LangGraph agent. -This test suite validates: -- Non-streaming event sending and polling -- Streaming event sending - -To run these tests: -1. Make sure the agent is running (via docker-compose or `agentex agents run`) -2. Set the AGENTEX_API_BASE_URL environment variable if not using default -3. Run: pytest test_agent.py -v +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) @@ -25,14 +19,12 @@ from agentex.types.agent_rpc_params import ParamsCreateTaskRequest from agentex.lib.sdk.fastacp.base.base_acp_server import uuid -# Configuration from environment variables AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") AGENT_NAME = os.environ.get("AGENT_NAME", "ab100-langgraph") @pytest_asyncio.fixture async def client(): - """Create an AsyncAgentex client instance for testing.""" client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) yield client await client.close() @@ -40,13 +32,11 @@ async def client(): @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: @@ -55,14 +45,9 @@ async def agent_id(client, agent_name): class TestNonStreamingEvents: - """Test non-streaming event sending and polling.""" - @pytest.mark.asyncio async def test_send_event(self, client: AsyncAgentex, agent_id: str): - """Test sending an event to the async LangGraph agent.""" - task_response = await client.agents.create_task( - agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex) - ) + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) task = task_response.result assert task is not None @@ -78,10 +63,7 @@ async def test_send_event(self, client: AsyncAgentex, agent_id: str): @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_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) task = task_response.result assert task is not None @@ -97,14 +79,9 @@ async def test_tool_calling(self, client: AsyncAgentex, agent_id: str): 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_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) task = task_response.result assert task is not None 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 index 6046b579a..db56979cc 100644 --- a/examples/tutorials/10_async/00_base/110_pydantic_ai/README.md +++ b/examples/tutorials/10_async/00_base/110_pydantic_ai/README.md @@ -1,63 +1,52 @@ -# Tutorial 110 (async/base): Pydantic AI Agent +# Async Pydantic AI Agent -This tutorial demonstrates how to build an **async** Pydantic AI agent on AgentEx with: -- Tool calling (Pydantic AI handles the tool loop internally) -- Streaming token output via Redis (text + reasoning tokens stream as deltas) -- Task lifecycle hooks (create / event-send / cancel) +A minimal **async** (Redis-streaming) Pydantic AI agent that drives the +**unified harness surface** (`UnifiedEmitter.auto_send_turn` + `PydanticAITurn`) +directly. -This is the async counterpart to the sync tutorial at [`00_sync/040_pydantic_ai`](../../../00_sync/040_pydantic_ai/). +## Why this agent exists -## Key Concepts +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. -### Async ACP -Unlike sync ACP (HTTP request/response with chunked streaming back), async ACP uses **Redis** for streaming. The HTTP call returns immediately when an event is acknowledged; the agent then pushes updates to Redis on its own schedule. The UI subscribes to Redis to receive deltas. +## How it wires the unified surface -### Pydantic AI Integration -- **Agent**: A single `pydantic_ai.Agent` that owns the model and tools. No graph required. -- **`@agent.tool_plain`**: Registers a Python function as a tool. Pydantic AI infers the schema from type hints and docstring. -- **`agent.run_stream_events(...)`**: Yields `AgentStreamEvent`s (`PartStartEvent` / `PartDeltaEvent` / `PartEndEvent` / `FunctionToolResultEvent`) as the model produces them. +In `project/acp.py`: -### Streaming -The helper `stream_pydantic_ai_events(stream, task_id)` consumes the Pydantic AI event stream and writes Agentex updates to Redis via `adk.streaming.streaming_task_message_context(...)`: -- **Text and thinking tokens** stream as Redis deltas inside coalesced contexts. -- **Tool requests and tool responses** are emitted as **discrete full messages** (no token-level arg streaming). To stream tool-call argument tokens, use the sync converter — see [`00_sync/040_pydantic_ai`](../../../00_sync/040_pydantic_ai/). - -## Files - -| File | Description | -|------|-------------| -| `project/acp.py` | Async ACP server with task lifecycle handlers | -| `project/agent.py` | Pydantic AI agent + tool registration | -| `project/tools.py` | Tool definitions (weather example) | -| `tests/test_agent.py` | Integration tests | -| `manifest.yaml` | Agent configuration | - -## Running Locally - -```bash -# From this directory -agentex agents run +```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) ``` -## Running Tests +- `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`). -```bash -pytest tests/test_agent.py -v -``` +## Files -## Sync vs Async — How the Code Differs +- `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). -This tutorial uses the same `project/agent.py` and `project/tools.py` as the sync version. The only meaningful differences live in `project/acp.py`: +## Tools -| Concern | Sync (`s040-pydantic-ai`) | Async (`ab110-pydantic-ai`) | -|---|---|---| -| ACP type | `FastACP.create(acp_type="sync")` | `FastACP.create(acp_type="async", config=AsyncACPConfig(type="base"))` | -| Handler hook | `@acp.on_message_send` returns/yields events | `@acp.on_task_event_send` returns nothing | -| Stream output | `yield event` (chunked HTTP) | `await context.stream_update(...)` (Redis) | -| Tool calls | Args stream as `ToolRequestDelta` tokens | Args arrive in one full message | -| Lifecycle | Ephemeral (no task hooks) | `on_task_create` + `on_task_cancel` form a durable task contract | +- `get_weather(city: str) -> str`: returns a fixed "sunny and 72°F" string. -## Notes +## Offline coverage -- Multi-turn conversation memory is not wired here. Pydantic AI does not ship a checkpointer; to add memory, load prior messages via `adk.messages.list(task_id=...)` and pass them to `agent.run_stream_events(..., message_history=...)`. -- Reasoning/thinking tokens are not exercised by `gpt-4o-mini`. Swap to a reasoning-capable model if you want to test that branch end-to-end. +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 index 583b07251..4aca13d44 100644 --- a/examples/tutorials/10_async/00_base/110_pydantic_ai/manifest.yaml +++ b/examples/tutorials/10_async/00_base/110_pydantic_ai/manifest.yaml @@ -17,7 +17,7 @@ local_development: agent: acp_type: async name: ab110-pydantic-ai - description: An async Pydantic AI agent with tool calling and Redis streaming + description: An async Pydantic AI harness test agent using the unified emitter surface temporal: enabled: false @@ -38,7 +38,7 @@ agent: - env_var_name: SGP_CLIENT_BASE_URL secret_name: sgp-client-base-url secret_key: url - + deployment: image: repository: "" @@ -47,7 +47,7 @@ deployment: global: agent: name: "ab110-pydantic-ai" - description: "An async Pydantic AI agent with tool calling and Redis streaming" + description: "An async Pydantic AI harness test agent using the unified emitter surface" replicaCount: 1 resources: requests: 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 index dc8a2de21..95b638f8b 100644 --- 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 @@ -1,13 +1,14 @@ -"""ACP handler for async Pydantic AI agent. +"""ACP handler for the async harness Pydantic AI test 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. +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. Without this, every turn would be a -fresh stateless run and the agent would forget the prior conversation. +then save the updated history back. """ from __future__ import annotations @@ -23,17 +24,15 @@ from pydantic_ai.messages import ModelMessagesTypeAdapter import agentex.lib.adk as adk -from project.agent import create_agent -from agentex.lib.adk import ( - stream_pydantic_ai_events, - create_pydantic_ai_tracing_handler, -) +from 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__) @@ -66,9 +65,7 @@ class ConversationState(BaseModel): ``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. + ``ModelMessage`` objects through JSON. """ history_json: str = "[]" @@ -77,11 +74,7 @@ class ConversationState(BaseModel): @acp.on_task_create async def handle_task_create(params: CreateTaskParams): - """Initialize per-task state on task creation. - - A fresh task starts with no message history; the conversation is built - up by ``handle_task_event_send`` on each subsequent user message. - """ + """Initialize per-task state on task creation.""" logger.info(f"Task created: {params.task.id}") await adk.state.create( task_id=params.task.id, @@ -92,7 +85,7 @@ async def handle_task_create(params: CreateTaskParams): @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.""" + """Handle each user message through the unified auto_send_turn path.""" agent = get_agent() task_id = params.task.id agent_id = params.agent.id @@ -103,9 +96,7 @@ async def handle_task_event_send(params: SendEventParams): # Echo the user's message into the task history. await adk.messages.create(task_id=task_id, content=params.event.content) - # Load the previous conversation history from state. If state is missing - # (e.g. task wasn't initialised via on_task_create), fall back to a fresh - # one so the agent still responds — just without memory of prior turns. + # 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() @@ -123,15 +114,15 @@ async def handle_task_event_send(params: SendEventParams): input={"message": user_message}, data={"__span_type__": "AGENT_WORKFLOW"}, ) as turn_span: - tracing_handler = create_pydantic_ai_tracing_handler( + # 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, - task_id=task_id, ) - # Wrap the pydantic-ai event stream so we can capture the final - # AgentRunResultEvent (which carries the full message list for the - # next turn) without changing the streaming-helper's signature. + # Capture the terminal AgentRunResultEvent to persist message history. captured_messages: list[Any] = [] async def tee_messages(upstream) -> AsyncIterator[Any]: @@ -141,9 +132,13 @@ async def tee_messages(upstream) -> AsyncIterator[Any]: yield event async with agent.run_stream_events(user_message, message_history=previous_messages) as stream: - final_output = await stream_pydantic_ai_events( - tee_messages(stream), task_id, tracing_handler=tracing_handler + # 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: @@ -156,7 +151,7 @@ async def tee_messages(upstream) -> AsyncIterator[Any]: ) if turn_span: - turn_span.output = {"final_output": final_output} + turn_span.output = {"final_output": result.final_text} @acp.on_task_cancel 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 index 2c0f6f10c..e7b764d82 100644 --- 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 @@ -1,4 +1,4 @@ -"""Pydantic AI agent definition. +"""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. @@ -12,6 +12,8 @@ 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. @@ -29,9 +31,7 @@ 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") - ), + system_prompt=SYSTEM_PROMPT.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")), ) agent.tool_plain(get_weather) 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 index 98f65d509..0f16a7cb0 100644 --- 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 @@ -1,8 +1,8 @@ -"""Tool definitions for the async Pydantic AI agent. +"""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 functions so they're -easy to unit-test in isolation. +(see project.agent). This module hosts the bare function so it is easy to +unit-test in isolation. """ from __future__ import annotations 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 index f5cd32e0a..257918014 100644 --- a/examples/tutorials/10_async/00_base/110_pydantic_ai/pyproject.toml +++ b/examples/tutorials/10_async/00_base/110_pydantic_ai/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "ab110-pydantic-ai" version = "0.1.0" -description = "An async Pydantic AI agent with tool calling and Redis streaming" +description = "An async Pydantic AI harness test agent using the unified emitter surface" readme = "README.md" requires-python = ">=3.12" dependencies = [ 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 index a31322d30..ce573a697 100644 --- 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 @@ -1,8 +1,10 @@ -"""Tests for the async Pydantic AI agent. +"""Live tests for the async Pydantic AI agent. -This test suite validates: -- Non-streaming event sending and polling -- Streaming event sending +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`) @@ -53,14 +55,12 @@ async def agent_id(client, agent_name): class TestNonStreamingEvents: - """Test non-streaming event sending and polling.""" + """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 Pydantic AI agent.""" - task_response = await client.agents.create_task( - agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex) - ) + """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 @@ -77,9 +77,7 @@ async def test_send_event(self, client: AsyncAgentex, agent_id: str): @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_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) task = task_response.result assert task is not None @@ -100,9 +98,7 @@ class TestStreamingEvents: @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_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) task = task_response.result assert task is not None diff --git a/examples/tutorials/00_sync/harness_pydantic_ai/.dockerignore b/examples/tutorials/10_async/00_base/120_openai_agents/.dockerignore similarity index 100% rename from examples/tutorials/00_sync/harness_pydantic_ai/.dockerignore rename to examples/tutorials/10_async/00_base/120_openai_agents/.dockerignore diff --git a/examples/tutorials/10_async/00_base/harness_langgraph/Dockerfile b/examples/tutorials/10_async/00_base/120_openai_agents/Dockerfile similarity index 70% rename from examples/tutorials/10_async/00_base/harness_langgraph/Dockerfile rename to examples/tutorials/10_async/00_base/120_openai_agents/Dockerfile index 3e0bd696a..76fe0fdef 100644 --- a/examples/tutorials/10_async/00_base/harness_langgraph/Dockerfile +++ b/examples/tutorials/10_async/00_base/120_openai_agents/Dockerfile @@ -23,16 +23,16 @@ 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/harness_langgraph/pyproject.toml /app/harness_langgraph/pyproject.toml -COPY 10_async/00_base/harness_langgraph/README.md /app/harness_langgraph/README.md +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/harness_langgraph +WORKDIR /app/120_openai_agents # Copy the project code -COPY 10_async/00_base/harness_langgraph/project /app/harness_langgraph/project +COPY 10_async/00_base/120_openai_agents/project /app/120_openai_agents/project # Copy the test files -COPY 10_async/00_base/harness_langgraph/tests /app/harness_langgraph/tests +COPY 10_async/00_base/120_openai_agents/tests /app/120_openai_agents/tests # Copy shared test utilities COPY test_utils /app/test_utils @@ -44,7 +44,7 @@ RUN uv pip install --system .[dev] pytest-asyncio httpx ENV PYTHONPATH=/app # Set test environment variables -ENV AGENT_NAME=a-harness-langgraph +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/130_harness_openai/README.md b/examples/tutorials/10_async/00_base/120_openai_agents/README.md similarity index 92% rename from examples/tutorials/10_async/00_base/130_harness_openai/README.md rename to examples/tutorials/10_async/00_base/120_openai_agents/README.md index ac439e4ed..0b55b00a2 100644 --- a/examples/tutorials/10_async/00_base/130_harness_openai/README.md +++ b/examples/tutorials/10_async/00_base/120_openai_agents/README.md @@ -5,7 +5,7 @@ delivers its output through the **unified harness surface**. ## What this demonstrates -Same `OpenAITurn` adapter as the sync tutorial (`060_harness_openai`), but the +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. diff --git a/examples/tutorials/10_async/00_base/130_harness_openai/manifest.yaml b/examples/tutorials/10_async/00_base/120_openai_agents/manifest.yaml similarity index 82% rename from examples/tutorials/10_async/00_base/130_harness_openai/manifest.yaml rename to examples/tutorials/10_async/00_base/120_openai_agents/manifest.yaml index 7e67675fa..bd8d5cce5 100644 --- a/examples/tutorials/10_async/00_base/130_harness_openai/manifest.yaml +++ b/examples/tutorials/10_async/00_base/120_openai_agents/manifest.yaml @@ -2,10 +2,10 @@ build: context: root: ../../../ include_paths: - - 10_async/00_base/130_harness_openai + - 10_async/00_base/120_openai_agents - test_utils - dockerfile: 10_async/00_base/130_harness_openai/Dockerfile - dockerignore: 10_async/00_base/130_harness_openai/.dockerignore + dockerfile: 10_async/00_base/120_openai_agents/Dockerfile + dockerignore: 10_async/00_base/120_openai_agents/.dockerignore local_development: agent: @@ -16,7 +16,7 @@ local_development: agent: acp_type: async - name: ab130-harness-openai + name: ab120-openai-agents description: An async OpenAI Agents SDK agent on the unified harness surface temporal: @@ -46,7 +46,7 @@ deployment: global: agent: - name: "ab130-harness-openai" + name: "ab120-openai-agents" description: "An async OpenAI Agents SDK agent on the unified harness surface" replicaCount: 1 resources: diff --git a/examples/tutorials/00_sync/harness_codex/project/__init__.py b/examples/tutorials/10_async/00_base/120_openai_agents/project/__init__.py similarity index 100% rename from examples/tutorials/00_sync/harness_codex/project/__init__.py rename to examples/tutorials/10_async/00_base/120_openai_agents/project/__init__.py diff --git a/examples/tutorials/10_async/00_base/130_harness_openai/project/acp.py b/examples/tutorials/10_async/00_base/120_openai_agents/project/acp.py similarity index 100% rename from examples/tutorials/10_async/00_base/130_harness_openai/project/acp.py rename to examples/tutorials/10_async/00_base/120_openai_agents/project/acp.py diff --git a/examples/tutorials/10_async/00_base/130_harness_openai/project/agent.py b/examples/tutorials/10_async/00_base/120_openai_agents/project/agent.py similarity index 100% rename from examples/tutorials/10_async/00_base/130_harness_openai/project/agent.py rename to examples/tutorials/10_async/00_base/120_openai_agents/project/agent.py diff --git a/examples/tutorials/10_async/00_base/130_harness_openai/project/tools.py b/examples/tutorials/10_async/00_base/120_openai_agents/project/tools.py similarity index 100% rename from examples/tutorials/10_async/00_base/130_harness_openai/project/tools.py rename to examples/tutorials/10_async/00_base/120_openai_agents/project/tools.py diff --git a/examples/tutorials/10_async/00_base/130_harness_openai/pyproject.toml b/examples/tutorials/10_async/00_base/120_openai_agents/pyproject.toml similarity index 95% rename from examples/tutorials/10_async/00_base/130_harness_openai/pyproject.toml rename to examples/tutorials/10_async/00_base/120_openai_agents/pyproject.toml index c05e8c1c6..f48fab49f 100644 --- a/examples/tutorials/10_async/00_base/130_harness_openai/pyproject.toml +++ b/examples/tutorials/10_async/00_base/120_openai_agents/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "ab130-harness-openai" +name = "ab120-openai-agents" version = "0.1.0" description = "An async OpenAI Agents SDK agent on the unified harness surface" readme = "README.md" diff --git a/examples/tutorials/10_async/00_base/130_harness_openai/tests/test_agent.py b/examples/tutorials/10_async/00_base/120_openai_agents/tests/test_agent.py similarity index 100% rename from examples/tutorials/10_async/00_base/130_harness_openai/tests/test_agent.py rename to examples/tutorials/10_async/00_base/120_openai_agents/tests/test_agent.py diff --git a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/Dockerfile b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/Dockerfile deleted file mode 100644 index 1272027cf..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/Dockerfile +++ /dev/null @@ -1,50 +0,0 @@ -# syntax=docker/dockerfile:1.3 -FROM python:3.12-slim -COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - htop \ - vim \ - curl \ - tar \ - python3-dev \ - postgresql-client \ - build-essential \ - libpq-dev \ - gcc \ - cmake \ - netcat-openbsd \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -RUN uv pip install --system --upgrade pip setuptools wheel - -ENV UV_HTTP_TIMEOUT=1000 - -# Copy pyproject.toml and README.md to install dependencies -COPY 10_async/00_base/120_openai_agents_local_sandbox/pyproject.toml /app/120_openai_agents_local_sandbox/pyproject.toml -COPY 10_async/00_base/120_openai_agents_local_sandbox/README.md /app/120_openai_agents_local_sandbox/README.md - -WORKDIR /app/120_openai_agents_local_sandbox - -# Copy the project code -COPY 10_async/00_base/120_openai_agents_local_sandbox/project /app/120_openai_agents_local_sandbox/project - -# Copy the test files -COPY 10_async/00_base/120_openai_agents_local_sandbox/tests /app/120_openai_agents_local_sandbox/tests - -# Copy shared test utilities -COPY test_utils /app/test_utils - -# Install the required Python packages with dev dependencies -RUN uv pip install --system .[dev] pytest-asyncio httpx - -# Set environment variables -ENV PYTHONPATH=/app - -# Set test environment variables -ENV AGENT_NAME=ab120-openai-agents-local-sandbox - -# Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/README.md b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/README.md deleted file mode 100644 index 58d422b39..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/README.md +++ /dev/null @@ -1,119 +0,0 @@ -# Tutorial 120: Async OpenAI Agents SDK with a Local Sandbox - -This tutorial demonstrates how to build an **async (non-Temporal)** agent on AgentEx -using the [OpenAI Agents SDK](https://developers.openai.com/api/docs/guides/agents) -and its **sandbox** runtime, running with the **local** (`unix_local`) backend. - -The agent is a "local sandbox assistant": it answers questions by actually running -real shell commands (e.g. `python3 --version`, `ls /tmp`, `python3 -c "..."`) -instead of guessing. - -This mirrors the Pydantic AI async tutorial (`110_pydantic_ai`): same async ACP -model (`acp_type: async`, `temporal.enabled: false`), same per-task `adk.state` -multi-turn memory pattern. The difference is the runtime — here we use the OpenAI -Agents SDK `SandboxAgent` with the local sandbox backend. - -## Key Concepts - -### Async ACP (base) -The async ACP model is event-driven: `on_task_create` initializes per-task state, -and `on_task_event_send` handles each user message. Conversation history is -persisted across turns via `adk.state`. - -### OpenAI Agents SDK Sandbox -The OpenAI Agents SDK ships `agents.sandbox`, which lets you give an agent -**capabilities** (instead of hand-written tools) that the runtime turns into real -tools backed by a sandbox: - -- **`SandboxAgent`**: an `Agent` that is granted sandbox capabilities. -- **Capabilities** (`from agents.sandbox.capabilities import Shell, Filesystem, Memory`): - each capability expands into a set of real tools. This tutorial uses `Shell`, which - lets the model run real shell commands. -- **`SandboxRunConfig`** + a sandbox **client**: tells the runtime *where* the tools - actually execute. - -### The LOCAL sandbox (`UnixLocalSandboxClient`) -This tutorial uses the local backend -(`from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient, UnixLocalSandboxClientOptions`), -`backend_id="unix_local"`. The local sandbox runs shell commands **ON THE HOST** — -the agent's own container/process. There is **no Docker, no Temporal, and no remote -sandbox infrastructure** involved. - -The sandbox is wired up through the SDK's `RunConfig`: - -```python -from agents import Runner, set_tracing_disabled -from agents.run_config import RunConfig -from agents.sandbox import SandboxAgent, SandboxRunConfig -from agents.sandbox.capabilities import Shell -from agents.sandbox.sandboxes.unix_local import ( - UnixLocalSandboxClient, - UnixLocalSandboxClientOptions, -) - -set_tracing_disabled(True) # avoid api.openai.com tracing 401 behind a gateway - -agent = SandboxAgent( - name="Local Sandbox Assistant", - instructions="...use the shell tools to actually run commands...", - capabilities=[Shell()], -) -run_config = RunConfig( - sandbox=SandboxRunConfig( - client=UnixLocalSandboxClient(), - options=UnixLocalSandboxClientOptions(), - ) -) -result = await Runner.run(agent, input=input_list, run_config=run_config) -print(result.final_output) -``` - -`Runner.run` drives the full tool-call loop internally: the model issues shell -commands, the local sandbox runs them on the host, the output is fed back, and the -loop continues until the model produces a final answer. Because the loop is -self-contained, the async handler runs the agent and persists a single final -`TextContent` rather than streaming tokens. - -## Files - -| File | Description | -|------|-------------| -| `project/acp.py` | Async ACP server + handlers (`adk.state` multi-turn, runs the sandbox agent) | -| `project/agent.py` | `SandboxAgent` + `RunConfig(sandbox=...)` wiring + `run_agent` | -| `project/tools.py` | Sandbox capability factory (`Shell`) | -| `tests/test_agent.py` | Integration tests (polling pattern) | -| `manifest.yaml` | Agent configuration | - -## Running Locally - -```bash -# From this directory -agentex agents run -``` - -Set `OPENAI_API_KEY` (or `LITELLM_API_KEY` if you're behind the Scale LiteLLM -gateway) in your environment or in a `.env` file in `project/` so the agent can call -the model. - -## Running Tests - -```bash -pytest tests/test_agent.py -v -``` - -## Notes - -- **No infra required.** Because this uses the `unix_local` backend, the shell tools - run directly in the agent's process — no Docker daemon, no Temporal, no remote - sandbox. Swap the client for a remote/containerized backend to isolate execution. -- **Tracing.** `set_tracing_disabled(True)` turns off the OpenAI Agents SDK's native - tracer (which would otherwise try to ship traces to `api.openai.com`). The manifest - also sets `OPENAI_AGENTS_DISABLE_TRACING=1`. AgentEx/SGP tracing still runs via the - tracing manager configured in `acp.py` when SGP credentials are present. -- **Capabilities are the tools.** To let the agent do more, add capabilities in - `project/tools.py` (e.g. `Filesystem()`, `Memory()`). - -## Further Reading - -- OpenAI Agents SDK guide: https://developers.openai.com/api/docs/guides/agents -- The Temporal variant of this tutorial: `10_async/10_temporal/120_openai_agents_local_sandbox` diff --git a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/manifest.yaml b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/manifest.yaml deleted file mode 100644 index e0c3c0596..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/manifest.yaml +++ /dev/null @@ -1,61 +0,0 @@ -build: - context: - root: ../../../ - include_paths: - - 10_async/00_base/120_openai_agents_local_sandbox - - test_utils - dockerfile: 10_async/00_base/120_openai_agents_local_sandbox/Dockerfile - dockerignore: 10_async/00_base/120_openai_agents_local_sandbox/.dockerignore - -local_development: - agent: - port: 8000 - host_address: host.docker.internal - paths: - acp: project/acp.py - -agent: - acp_type: async - name: ab120-openai-agents-local-sandbox - description: An async OpenAI Agents SDK agent using a local (unix_local) sandbox - - temporal: - enabled: false - - credentials: - - env_var_name: OPENAI_API_KEY - secret_name: openai-api-key - secret_key: api-key - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - - env_var_name: SGP_API_KEY - secret_name: sgp-api-key - secret_key: api-key - - env_var_name: SGP_ACCOUNT_ID - secret_name: sgp-account-id - secret_key: account-id - - env_var_name: SGP_CLIENT_BASE_URL - secret_name: sgp-client-base-url - secret_key: url - - env: - OPENAI_AGENTS_DISABLE_TRACING: "1" - -deployment: - image: - repository: "" - tag: "latest" - - global: - agent: - name: "ab120-openai-agents-local-sandbox" - description: "An async OpenAI Agents SDK agent using a local (unix_local) sandbox" - replicaCount: 1 - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" diff --git a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/acp.py b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/acp.py deleted file mode 100644 index 6ff475873..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/acp.py +++ /dev/null @@ -1,149 +0,0 @@ -"""ACP handler for the async OpenAI Agents SDK local-sandbox agent. - -Uses the async ACP model (``acp_type: async``, ``temporal.enabled: false``), -mirroring the Pydantic AI tutorial (110). The difference is the runtime: here we -run an OpenAI Agents SDK ``SandboxAgent`` against the **local** sandbox backend -(``UnixLocalSandboxClient``), which executes real shell commands on the host. - -The OpenAI Agents SDK sandbox runtime drives the full tool-call loop internally -inside ``Runner.run`` (model -> shell command -> output -> model -> ... -> final -answer), so this handler runs the agent and persists a single final -``TextContent`` rather than streaming tokens itself. - -Multi-turn memory is persisted via ``adk.state``: on each turn we load the prior -OpenAI Agents SDK input list from state, run the agent with it, then save the -updated list (``result.to_input_list()``) back. Without this, every turn would be -a fresh stateless run and the agent would forget the prior conversation. -""" - -from __future__ import annotations - -import os -from typing import Any - -from dotenv import load_dotenv - -load_dotenv() - -import agentex.lib.adk as adk -from project.agent import run_agent -from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams -from agentex.lib.types.fastacp import AsyncACPConfig -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.types.text_content import TextContent -from agentex.lib.utils.model_utils import BaseModel -from agentex.lib.sdk.fastacp.fastacp import FastACP -from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config - -logger = make_logger(__name__) - -# LiteLLM proxy auth: copy LITELLM_API_KEY to OPENAI_API_KEY for OpenAI client -# compatibility, so the same example works behind the Scale LiteLLM gateway. -_litellm_key = os.environ.get("LITELLM_API_KEY") -if _litellm_key and not os.environ.get("OPENAI_API_KEY"): - os.environ["OPENAI_API_KEY"] = _litellm_key - -add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SGP_API_KEY", ""), - sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), - sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), - ) -) - -acp = FastACP.create( - acp_type="async", - config=AsyncACPConfig(type="base"), -) - - -class ConversationState(BaseModel): - """Per-task conversation state persisted via ``adk.state``. - - ``input_list`` holds the OpenAI Agents SDK conversation history — the same - structure ``Runner.run`` accepts as input and ``result.to_input_list()`` - returns. Persisting it between turns gives the agent multi-turn memory. - """ - - input_list: list[dict[str, Any]] = [] - turn_number: int = 0 - - -@acp.on_task_create -async def handle_task_create(params: CreateTaskParams): - """Initialize per-task state on task creation. - - A fresh task starts with no message history; the conversation is built up by - ``handle_task_event_send`` on each subsequent user message. - """ - logger.info(f"Task created: {params.task.id}") - await adk.state.create( - task_id=params.task.id, - agent_id=params.agent.id, - state=ConversationState(), - ) - - -@acp.on_task_event_send -async def handle_task_event_send(params: SendEventParams): - """Handle each user message: load prior history, run the agent, save updated history.""" - task_id = params.task.id - agent_id = params.agent.id - user_message = params.event.content.content - - logger.info(f"Processing message for thread {task_id}") - - # Echo the user's message into the task history so it shows up in the UI. - await adk.messages.create(task_id=task_id, content=params.event.content) - - # Load the previous conversation history from state. If state is missing - # (e.g. task wasn't initialised via on_task_create), fall back to a fresh - # one so the agent still responds — just without memory of prior turns. - task_state = await adk.state.get_by_task_and_agent(task_id=task_id, agent_id=agent_id) - if task_state is None: - state = ConversationState() - task_state = await adk.state.create(task_id=task_id, agent_id=agent_id, state=state) - else: - state = ConversationState.model_validate(task_state.state) - - state.turn_number += 1 - state.input_list.append({"role": "user", "content": user_message}) - - async with adk.tracing.span( - trace_id=task_id, - task_id=task_id, - name=f"Turn {state.turn_number}", - input={"message": user_message}, - data={"__span_type__": "AGENT_WORKFLOW"}, - ) as turn_span: - # The OpenAI Agents SDK sandbox runtime runs the full tool-call loop - # internally (model -> shell command on the local host -> output -> - # model -> ... -> final answer), so we get a single final result. - result = await run_agent(state.input_list) - final_output = result.final_output - - # Persist the assistant's final answer as a TaskMessage so it shows up - # in the UI. (Unlike the streaming Pydantic AI tutorial, the sandbox run - # is non-streaming, so we post the final text ourselves.) - await adk.messages.create( - task_id=task_id, - content=TextContent(author="agent", content=final_output), - ) - - # Save the updated message history so the next turn picks up here. - state.input_list = result.to_input_list() - await adk.state.update( - state_id=task_state.id, - task_id=task_id, - agent_id=agent_id, - state=state, - ) - - if turn_span: - turn_span.output = {"final_output": final_output} - - -@acp.on_task_cancel -async def handle_task_canceled(params: CancelTaskParams): - logger.info(f"Task canceled: {params.task.id}") diff --git a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/agent.py b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/agent.py deleted file mode 100644 index 177bb287d..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/agent.py +++ /dev/null @@ -1,95 +0,0 @@ -"""OpenAI Agents SDK local-sandbox agent definition (async, non-Temporal). - -This mirrors the Pydantic AI tutorial (110): the agent is the boundary between -this module and the API layer (acp.py). The difference is the runtime — here we -use the OpenAI Agents SDK ``SandboxAgent`` together with the **local** sandbox -backend (``UnixLocalSandboxClient``). - -The local sandbox runs shell commands ON THE HOST — the agent's own -container/process. There is no Docker, no Temporal, and no remote sandbox -infrastructure. The OpenAI Agents SDK runs its own tool-call loop internally: -when the model decides to run a shell command, the sandbox executes it locally -and feeds the output back to the model until it produces a final answer. -""" - -from __future__ import annotations - -from datetime import datetime - -from agents import Runner, set_tracing_disabled -from agents.sandbox import SandboxAgent, SandboxRunConfig -from agents.run_config import RunConfig -from agents.sandbox.sandboxes.unix_local import ( - UnixLocalSandboxClient, - UnixLocalSandboxClientOptions, -) - -from project.tools import get_capabilities - -# Disable the openai-agents SDK's native tracer so it doesn't ship traces to -# api.openai.com using OPENAI_API_KEY (which may be a gateway/proxy key and would -# 401). Agentex tracing still runs via the tracing manager configured in acp.py. -set_tracing_disabled(True) - -MODEL_NAME = "gpt-4o-mini" -INSTRUCTIONS = """You are a local sandbox assistant. - -Current date and time: {timestamp} - -You have access to shell tools that run real commands on the local machine. - -Guidelines: -- ALWAYS use the shell tools to actually run commands — never guess or make up - output. If the user asks for the Python version, run `python3 --version`. If - they ask to list files, run `ls`. If they ask you to compute something, use - `python3 -c "..."`. -- Run the minimal command(s) needed to answer the question. -- Report the real command output back to the user, concisely. -""" - - -def create_agent() -> SandboxAgent: - """Build and return the OpenAI Agents SDK sandbox agent. - - The agent is granted shell capabilities (see ``project.tools``). The actual - sandbox backend (where the shell commands run) is supplied at run time via - the ``RunConfig`` returned by ``create_run_config``. - """ - return SandboxAgent( - name="Local Sandbox Assistant", - model=MODEL_NAME, - instructions=INSTRUCTIONS.format( - timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") - ), - capabilities=get_capabilities(), - ) - - -def create_run_config() -> RunConfig: - """Build the RunConfig that points the agent at the LOCAL sandbox backend. - - ``UnixLocalSandboxClient`` (backend_id="unix_local") runs shell commands on - the host — the agent's own process — so no Docker or remote infra is needed. - """ - return RunConfig( - sandbox=SandboxRunConfig( - client=UnixLocalSandboxClient(), - options=UnixLocalSandboxClientOptions(), - ) - ) - - -async def run_agent(input_list: list) -> "Runner": - """Run the sandbox agent over the conversation so far and return the result. - - The OpenAI Agents SDK handles the full tool-call loop internally: the model - issues shell commands, the local sandbox runs them on the host, and the - output is fed back until the model produces a final answer. - - We pass the full ``input_list`` (prior turns + the new user message) so the - agent has conversation memory across turns; the caller persists - ``result.to_input_list()`` back into ``adk.state`` for the next turn. - """ - agent = create_agent() - run_config = create_run_config() - return await Runner.run(agent, input=input_list, run_config=run_config, max_turns=10) diff --git a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/tools.py b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/tools.py deleted file mode 100644 index a931fa273..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/tools.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Sandbox capabilities for the async OpenAI Agents SDK local-sandbox agent. - -Unlike the Pydantic AI tutorial (110), this agent does not register hand-written -Python functions as tools. Instead it is given *capabilities* — the OpenAI Agents -SDK sandbox runtime turns each capability into a real set of tools (run a shell -command, read a file, etc.) backed by an actual sandbox backend. - -Here we use the ``Shell`` capability, which lets the model run real shell commands. -With the local (``unix_local``) backend those commands execute ON THE HOST — the -agent's own process/container — so there is no Docker, Temporal, or remote infra -involved. This module hosts the capability factory so the agent wiring in -``project.agent`` stays readable and the capability set is easy to extend -(e.g. add ``Filesystem()`` or ``Memory()``). -""" - -from __future__ import annotations - -from agents.sandbox.capabilities import Shell - - -def get_capabilities() -> list: - """Return the sandbox capabilities the agent is allowed to use. - - Returns: - A list of OpenAI Agents SDK sandbox capabilities. We grant ``Shell`` so - the agent can run real shell commands on the local machine. Add - ``Filesystem()`` or ``Memory()`` here to expand what the agent can do. - """ - return [Shell()] diff --git a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/pyproject.toml b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/pyproject.toml deleted file mode 100644 index 75c6254f3..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/pyproject.toml +++ /dev/null @@ -1,36 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "ab120-openai-agents-local-sandbox" -version = "0.1.0" -description = "An async OpenAI Agents SDK agent using a local (unix_local) sandbox" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "agentex-sdk", - "scale-gp", - "openai-agents>=0.14.3,<0.15", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "httpx", - "black", - "isort", - "flake8", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] - -[tool.black] -line-length = 88 -target-version = ['py312'] - -[tool.isort] -profile = "black" -line_length = 88 diff --git a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/tests/test_agent.py b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/tests/test_agent.py deleted file mode 100644 index 0c7904eac..000000000 --- a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/tests/test_agent.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Tests for the async OpenAI Agents SDK local-sandbox agent. - -This test suite validates that the agent actually runs shell commands in the -LOCAL sandbox (unix_local backend) by polling for the agent's response: -- Ask for the Python version -> response contains "Python 3" -- Ask it to compute 21 * 2 with python3 -> response contains "42" - -To run these tests: -1. Make sure the agent is running (via docker-compose or `agentex agents run`) -2. Set the AGENTEX_API_BASE_URL environment variable if not using default -3. Run: pytest test_agent.py -v - -Configuration: -- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) -- AGENT_NAME: Name of the agent to test (default: ab120-openai-agents-local-sandbox) -""" - -import os -import uuid - -import pytest -import pytest_asyncio -from test_utils.async_utils import send_event_and_poll_yielding - -from agentex import AsyncAgentex -from agentex.types.agent_rpc_params import ParamsCreateTaskRequest - -AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") -AGENT_NAME = os.environ.get("AGENT_NAME", "ab120-openai-agents-local-sandbox") - - -@pytest_asyncio.fixture -async def client(): - """Create an AsyncAgentex client instance for testing.""" - client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) - yield client - await client.close() - - -@pytest.fixture -def agent_name(): - """Return the agent name for testing.""" - return AGENT_NAME - - -@pytest_asyncio.fixture -async def agent_id(client, agent_name): - """Retrieve the agent ID based on the agent name.""" - agents = await client.agents.list() - for agent in agents: - if agent.name == agent_name: - return agent.id - raise ValueError(f"Agent with name {agent_name} not found.") - - -async def _send_and_collect_agent_text( - client: AsyncAgentex, agent_id: str, task_id: str, user_message: str -) -> str: - """Send a user message and accumulate all agent text responses into a string.""" - parts: list[str] = [] - async for message in send_event_and_poll_yielding( - client=client, - agent_id=agent_id, - task_id=task_id, - user_message=user_message, - timeout=60, - sleep_interval=1.0, - yield_updates=True, - ): - content = message.content - if content and content.type == "text" and content.author == "agent": - if content.content and content.content not in parts: - parts.append(content.content) - return "\n".join(parts) - - -class TestLocalSandboxEvents: - """Test the async local-sandbox OpenAI Agents SDK agent.""" - - @pytest.mark.asyncio - async def test_shell_python_version(self, client: AsyncAgentex, agent_id: str): - """The agent should run `python3 --version` in the local sandbox. - - The sandbox runs on Python 3.12, so the real output contains "Python 3". - """ - task_response = await client.agents.create_task( - agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex) - ) - task = task_response.result - assert task is not None - - text = await _send_and_collect_agent_text( - client, - agent_id, - task.id, - "Use your shell to print the Python version on this machine, then " - "tell me what it is.", - ) - assert text, "Expected a non-empty response from the sandbox agent." - assert "Python 3" in text - - @pytest.mark.asyncio - async def test_shell_compute(self, client: AsyncAgentex, agent_id: str): - """The agent should use python3 in the sandbox to compute 21 * 2 == 42.""" - task_response = await client.agents.create_task( - agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex) - ) - task = task_response.result - assert task is not None - - text = await _send_and_collect_agent_text( - client, - agent_id, - task.id, - "Use python3 in your shell to compute 21 * 2 and tell me the result.", - ) - assert text, "Expected a non-empty response from the sandbox agent." - assert "42" in text - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/00_base/130_harness_openai/Dockerfile b/examples/tutorials/10_async/00_base/130_harness_openai/Dockerfile deleted file mode 100644 index a31c89a31..000000000 --- a/examples/tutorials/10_async/00_base/130_harness_openai/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/130_harness_openai/pyproject.toml /app/130_harness_openai/pyproject.toml -COPY 10_async/00_base/130_harness_openai/README.md /app/130_harness_openai/README.md - -WORKDIR /app/130_harness_openai - -# Copy the project code -COPY 10_async/00_base/130_harness_openai/project /app/130_harness_openai/project - -# Copy the test files -COPY 10_async/00_base/130_harness_openai/tests /app/130_harness_openai/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=ab130-harness-openai - -# 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/130_harness_openai/project/__init__.py b/examples/tutorials/10_async/00_base/130_harness_openai/project/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/.dockerignore b/examples/tutorials/10_async/00_base/140_codex/.dockerignore similarity index 100% rename from examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/.dockerignore rename to examples/tutorials/10_async/00_base/140_codex/.dockerignore diff --git a/examples/tutorials/10_async/00_base/harness_codex/Dockerfile b/examples/tutorials/10_async/00_base/140_codex/Dockerfile similarity index 64% rename from examples/tutorials/10_async/00_base/harness_codex/Dockerfile rename to examples/tutorials/10_async/00_base/140_codex/Dockerfile index 06b76aae2..ca5b99ffe 100644 --- a/examples/tutorials/10_async/00_base/harness_codex/Dockerfile +++ b/examples/tutorials/10_async/00_base/140_codex/Dockerfile @@ -22,18 +22,18 @@ RUN uv pip install --system --upgrade pip setuptools wheel ENV UV_HTTP_TIMEOUT=1000 -COPY 10_async/00_base/harness_codex/pyproject.toml /app/harness_codex/pyproject.toml -COPY 10_async/00_base/harness_codex/README.md /app/harness_codex/README.md +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/harness_codex +WORKDIR /app/140_codex -COPY 10_async/00_base/harness_codex/project /app/harness_codex/project -COPY 10_async/00_base/harness_codex/tests /app/harness_codex/tests +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=ab-harness-codex +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/harness_codex/README.md b/examples/tutorials/10_async/00_base/140_codex/README.md similarity index 94% rename from examples/tutorials/10_async/00_base/harness_codex/README.md rename to examples/tutorials/10_async/00_base/140_codex/README.md index 9bbcd927a..a00ddb562 100644 --- a/examples/tutorials/10_async/00_base/harness_codex/README.md +++ b/examples/tutorials/10_async/00_base/140_codex/README.md @@ -1,4 +1,4 @@ -# harness_codex (async base) +# 140_codex (async base) Tutorial agent demonstrating the `convert_codex_to_agentex_events` tap, `CodexTurn`, and `UnifiedEmitter` for an **async** (Redis-streaming, no Temporal) @@ -28,7 +28,7 @@ Live runs require: ```bash cd /path/to/scale-agentex-python -uv run --all-packages --all-extras pytest examples/tutorials/10_async/00_base/harness_codex/tests/test_agent.py -q +uv run --all-packages --all-extras pytest examples/tutorials/10_async/00_base/140_codex/tests/test_agent.py -q ``` ## Running live integration tests diff --git a/examples/tutorials/10_async/00_base/harness_codex/conftest.py b/examples/tutorials/10_async/00_base/140_codex/conftest.py similarity index 100% rename from examples/tutorials/10_async/00_base/harness_codex/conftest.py rename to examples/tutorials/10_async/00_base/140_codex/conftest.py diff --git a/examples/tutorials/10_async/00_base/harness_codex/manifest.yaml b/examples/tutorials/10_async/00_base/140_codex/manifest.yaml similarity index 84% rename from examples/tutorials/10_async/00_base/harness_codex/manifest.yaml rename to examples/tutorials/10_async/00_base/140_codex/manifest.yaml index e88e2029d..be020b141 100644 --- a/examples/tutorials/10_async/00_base/harness_codex/manifest.yaml +++ b/examples/tutorials/10_async/00_base/140_codex/manifest.yaml @@ -2,10 +2,10 @@ build: context: root: ../../../ include_paths: - - 10_async/00_base/harness_codex + - 10_async/00_base/140_codex - test_utils - dockerfile: 10_async/00_base/harness_codex/Dockerfile - dockerignore: 10_async/00_base/harness_codex/.dockerignore + dockerfile: 10_async/00_base/140_codex/Dockerfile + dockerignore: 10_async/00_base/140_codex/.dockerignore local_development: agent: @@ -16,7 +16,7 @@ local_development: agent: acp_type: async - name: ab-harness-codex + name: ab140-codex description: Async (base) tutorial agent driving the unified harness surface via local codex CLI subprocess temporal: @@ -46,7 +46,7 @@ deployment: global: agent: - name: "ab-harness-codex" + name: "ab140-codex" description: "Async (base) tutorial agent driving the unified harness surface via local codex CLI subprocess" replicaCount: 1 resources: diff --git a/examples/tutorials/00_sync/harness_langgraph/project/__init__.py b/examples/tutorials/10_async/00_base/140_codex/project/__init__.py similarity index 100% rename from examples/tutorials/00_sync/harness_langgraph/project/__init__.py rename to examples/tutorials/10_async/00_base/140_codex/project/__init__.py diff --git a/examples/tutorials/10_async/00_base/harness_codex/project/acp.py b/examples/tutorials/10_async/00_base/140_codex/project/acp.py similarity index 100% rename from examples/tutorials/10_async/00_base/harness_codex/project/acp.py rename to examples/tutorials/10_async/00_base/140_codex/project/acp.py diff --git a/examples/tutorials/10_async/00_base/harness_codex/pyproject.toml b/examples/tutorials/10_async/00_base/140_codex/pyproject.toml similarity index 96% rename from examples/tutorials/10_async/00_base/harness_codex/pyproject.toml rename to examples/tutorials/10_async/00_base/140_codex/pyproject.toml index c25a65c47..bdf7c462f 100644 --- a/examples/tutorials/10_async/00_base/harness_codex/pyproject.toml +++ b/examples/tutorials/10_async/00_base/140_codex/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "ab-harness-codex" +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" diff --git a/examples/tutorials/10_async/00_base/harness_codex/tests/test_agent.py b/examples/tutorials/10_async/00_base/140_codex/tests/test_agent.py similarity index 99% rename from examples/tutorials/10_async/00_base/harness_codex/tests/test_agent.py rename to examples/tutorials/10_async/00_base/140_codex/tests/test_agent.py index b50ee9116..68ca5aded 100644 --- a/examples/tutorials/10_async/00_base/harness_codex/tests/test_agent.py +++ b/examples/tutorials/10_async/00_base/140_codex/tests/test_agent.py @@ -129,7 +129,7 @@ async def test_yield_turn_is_passthrough(self): 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", "ab-harness-codex") +AGENT_NAME = os.environ.get("AGENT_NAME", "ab140-codex") @pytest.mark.skipif( diff --git a/examples/tutorials/10_async/00_base/harness_codex/project/__init__.py b/examples/tutorials/10_async/00_base/harness_codex/project/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/10_async/00_base/harness_langgraph/README.md b/examples/tutorials/10_async/00_base/harness_langgraph/README.md deleted file mode 100644 index 7efe28207..000000000 --- a/examples/tutorials/10_async/00_base/harness_langgraph/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# Tutorial: Async Harness 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) -``` - -Compare with ``100_langgraph``, which uses the bespoke -``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 (identical to 100_langgraph) | -| `project/tools.py` | Tool definitions (weather example) | -| `tests/test_agent.py` | Integration tests | -| `manifest.yaml` | Agent configuration (name: a-harness-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/harness_langgraph/manifest.yaml b/examples/tutorials/10_async/00_base/harness_langgraph/manifest.yaml deleted file mode 100644 index bb19e25b3..000000000 --- a/examples/tutorials/10_async/00_base/harness_langgraph/manifest.yaml +++ /dev/null @@ -1,58 +0,0 @@ -build: - context: - root: ../../../ - include_paths: - - 10_async/00_base/harness_langgraph - - test_utils - dockerfile: 10_async/00_base/harness_langgraph/Dockerfile - dockerignore: 10_async/00_base/harness_langgraph/.dockerignore - -local_development: - agent: - port: 8000 - host_address: host.docker.internal - paths: - acp: project/acp.py - -agent: - acp_type: async - name: a-harness-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: "a-harness-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/harness_langgraph/project/__init__.py b/examples/tutorials/10_async/00_base/harness_langgraph/project/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/10_async/00_base/harness_langgraph/project/acp.py b/examples/tutorials/10_async/00_base/harness_langgraph/project/acp.py deleted file mode 100644 index a99395424..000000000 --- a/examples/tutorials/10_async/00_base/harness_langgraph/project/acp.py +++ /dev/null @@ -1,109 +0,0 @@ -"""ACP handler for async harness 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. - -Differences from ``100_langgraph`` (bespoke path): -- No ``create_langgraph_tracing_handler`` boilerplate. -- ``stream_langgraph_events`` is replaced by - ``UnifiedEmitter.auto_send_turn(LangGraphTurn(stream))``. -- 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/harness_langgraph/project/graph.py b/examples/tutorials/10_async/00_base/harness_langgraph/project/graph.py deleted file mode 100644 index 4aeac3b3c..000000000 --- a/examples/tutorials/10_async/00_base/harness_langgraph/project/graph.py +++ /dev/null @@ -1,67 +0,0 @@ -"""LangGraph graph definition for the harness_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/harness_langgraph/project/tools.py b/examples/tutorials/10_async/00_base/harness_langgraph/project/tools.py deleted file mode 100644 index 6e7614300..000000000 --- a/examples/tutorials/10_async/00_base/harness_langgraph/project/tools.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Tool definitions for the harness_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/harness_langgraph/pyproject.toml b/examples/tutorials/10_async/00_base/harness_langgraph/pyproject.toml deleted file mode 100644 index 69856e6db..000000000 --- a/examples/tutorials/10_async/00_base/harness_langgraph/pyproject.toml +++ /dev/null @@ -1,37 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "a-harness-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/harness_langgraph/tests/test_agent.py b/examples/tutorials/10_async/00_base/harness_langgraph/tests/test_agent.py deleted file mode 100644 index 762b2b90c..000000000 --- a/examples/tutorials/10_async/00_base/harness_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: a-harness-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", "a-harness-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/harness_pydantic_ai/Dockerfile b/examples/tutorials/10_async/00_base/harness_pydantic_ai/Dockerfile deleted file mode 100644 index 3c1b9dfea..000000000 --- a/examples/tutorials/10_async/00_base/harness_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/harness_pydantic_ai/pyproject.toml /app/harness_pydantic_ai/pyproject.toml -COPY 10_async/00_base/harness_pydantic_ai/README.md /app/harness_pydantic_ai/README.md - -WORKDIR /app/harness_pydantic_ai - -# Copy the project code -COPY 10_async/00_base/harness_pydantic_ai/project /app/harness_pydantic_ai/project - -# Copy the test files -COPY 10_async/00_base/harness_pydantic_ai/tests /app/harness_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=ab-harness-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/harness_pydantic_ai/README.md b/examples/tutorials/10_async/00_base/harness_pydantic_ai/README.md deleted file mode 100644 index 51acb62bd..000000000 --- a/examples/tutorials/10_async/00_base/harness_pydantic_ai/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# Async Pydantic AI Harness Test 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 - -The `10_async/00_base/110_pydantic_ai` tutorial streams via the -`stream_pydantic_ai_events` helper (which uses the unified surface internally). -This harness test 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 at -`tests/lib/core/harness/test_harness_pydantic_ai_async.py`. diff --git a/examples/tutorials/10_async/00_base/harness_pydantic_ai/manifest.yaml b/examples/tutorials/10_async/00_base/harness_pydantic_ai/manifest.yaml deleted file mode 100644 index f9e50f329..000000000 --- a/examples/tutorials/10_async/00_base/harness_pydantic_ai/manifest.yaml +++ /dev/null @@ -1,58 +0,0 @@ -build: - context: - root: ../../../ - include_paths: - - 10_async/00_base/harness_pydantic_ai - - test_utils - dockerfile: 10_async/00_base/harness_pydantic_ai/Dockerfile - dockerignore: 10_async/00_base/harness_pydantic_ai/.dockerignore - -local_development: - agent: - port: 8000 - host_address: host.docker.internal - paths: - acp: project/acp.py - -agent: - acp_type: async - name: ab-harness-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: "ab-harness-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/harness_pydantic_ai/project/__init__.py b/examples/tutorials/10_async/00_base/harness_pydantic_ai/project/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/10_async/00_base/harness_pydantic_ai/project/acp.py b/examples/tutorials/10_async/00_base/harness_pydantic_ai/project/acp.py deleted file mode 100644 index 95b638f8b..000000000 --- a/examples/tutorials/10_async/00_base/harness_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/harness_pydantic_ai/project/agent.py b/examples/tutorials/10_async/00_base/harness_pydantic_ai/project/agent.py deleted file mode 100644 index e7b764d82..000000000 --- a/examples/tutorials/10_async/00_base/harness_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/harness_pydantic_ai/project/tools.py b/examples/tutorials/10_async/00_base/harness_pydantic_ai/project/tools.py deleted file mode 100644 index 0f16a7cb0..000000000 --- a/examples/tutorials/10_async/00_base/harness_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/harness_pydantic_ai/pyproject.toml b/examples/tutorials/10_async/00_base/harness_pydantic_ai/pyproject.toml deleted file mode 100644 index 3dc1e0e41..000000000 --- a/examples/tutorials/10_async/00_base/harness_pydantic_ai/pyproject.toml +++ /dev/null @@ -1,36 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "ab-harness-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/harness_pydantic_ai/tests/test_agent.py b/examples/tutorials/10_async/00_base/harness_pydantic_ai/tests/test_agent.py deleted file mode 100644 index 11098c7d5..000000000 --- a/examples/tutorials/10_async/00_base/harness_pydantic_ai/tests/test_agent.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Live tests for the async harness 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. They mirror the -``110_pydantic_ai`` async tutorial tests but target this harness agent. - -Offline coverage of the same wiring (TestModel + fake streaming/tracing) lives -in ``tests/lib/core/harness/test_harness_pydantic_ai_async.py`` in the SDK repo. - -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: ab-harness-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", "ab-harness-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/10_temporal/110_pydantic_ai/README.md b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/README.md index b221c1238..66466693b 100644 --- a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/README.md +++ b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/README.md @@ -1,153 +1,59 @@ -# Tutorial 110 (temporal): Pydantic AI Agent +# Temporal Pydantic AI Agent -This tutorial demonstrates a **durable** Pydantic AI agent on AgentEx, backed by Temporal: -- Workflow state survives crashes mid-conversation (Temporal replay) -- Every LLM call and every tool call becomes its own Temporal activity (independent retries + observability) -- Streaming via Redis still works — token-by-token deltas appear in the UI in real time +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`. -This is the Temporal counterpart to the async base tutorial at [`10_async/00_base/110_pydantic_ai/`](../../00_base/110_pydantic_ai/). +## Why this agent exists -## Why Temporal? Why not just async? +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. -In async base 110, the agent state lives in memory inside the ACP process. If that process dies mid-LLM-call, the in-flight turn is lost. Temporal fixes this by: +## How it wires the unified surface -1. Recording every external interaction (LLM call, tool call) to a durable event log. -2. On worker restart, **replaying** the workflow code, using cached activity results to skip work that already finished. -3. Letting workflows live forever — multi-day conversations or human-in-the-loop flows just work. - -## Architecture at a glance - -Two long-running processes plus shared infrastructure: - -``` -┌──────────────────────────┐ ┌──────────────────────────┐ -│ uvicorn project.acp:acp │ │ python -m run_worker │ -│ (HTTP shim, forwards │ │ (executes workflows + │ -│ signals to Temporal) │ │ activities) │ -└──────────────────────────┘ └──────────────────────────┘ - │ │ - └────► Temporal server ◄───────────┘ - (event log + queue) - - Redis ◄─── activities push deltas - │ - └─── Agentex API tails ──► UI client -``` - -The HTTP server is a thin shim that translates `task/event/send` into Temporal signals. The worker is where your agent code actually runs. Temporal sits in between, recording everything. - -## Key code patterns - -### `project/agent.py` — wrap the base agent in `TemporalAgent` - -```python -base_agent = Agent(MODEL_NAME, deps_type=TaskDeps, system_prompt=...) -base_agent.tool_plain(get_weather) - -temporal_agent = TemporalAgent( - base_agent, - name="at110_pydantic_ai_agent", - event_stream_handler=event_handler, # streams to Redis from inside the model activity -) -``` - -`TemporalAgent` (from `pydantic_ai.durable_exec.temporal`) wraps a normal Pydantic AI Agent so that: -- Each LLM call runs in its own activity -- Each tool call runs in its own activity -- The wrapping is invisible to the workflow code that calls `temporal_agent.run(...)` - -### `project/workflow.py` — declare `__pydantic_ai_agents__` +In `project/agent.py`, the `event_stream_handler` runs inside the model activity +and constructs a `UnifiedEmitter` from `RunContext.deps`: ```python -@workflow.defn(name=environment_variables.WORKFLOW_NAME) -class At110PydanticAiWorkflow(BaseWorkflow): - __pydantic_ai_agents__ = [temporal_agent] # ← discovered by PydanticAIPlugin - - @workflow.signal(name=SignalName.RECEIVE_EVENT) - async def on_task_event_send(self, params): - await adk.messages.create(task_id=params.task.id, content=params.event.content) - result = await temporal_agent.run( - params.event.content.content, - deps=TaskDeps(task_id=params.task.id), - ) +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 `__pydantic_ai_agents__` attribute is how `PydanticAIPlugin` discovers which activities to register on the worker — no manual activity list needed. - -### `project/acp.py` — no handlers, just plugin wiring - -```python -acp = FastACP.create( - acp_type="async", - config=TemporalACPConfig( - type="temporal", - temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), - plugins=[PydanticAIPlugin()], - ), -) -``` - -When `type="temporal"`, FastACP auto-wires HTTP → workflow signals. You don't define `@acp.on_task_event_send` anywhere — Temporal handles it. - -### `project/run_worker.py` — boot the worker with the plugin - -```python -worker = AgentexWorker( - task_queue=task_queue_name, - plugins=[PydanticAIPlugin()], -) -await worker.run( - activities=get_all_activities(), - workflow=At110PydanticAiWorkflow, -) -``` - -`get_all_activities()` returns the built-in Agentex activities (state, messages, streaming, tracing). Pydantic AI's per-agent activities are auto-added by the plugin. - -## Files - -| File | Purpose | -|------|---------| -| `project/acp.py` | Thin HTTP shim — `FastACP.create(type="temporal", ...)` | -| `project/workflow.py` | `@workflow.defn` class with the signal handler | -| `project/agent.py` | Base Pydantic AI Agent wrapped in `TemporalAgent` | -| `project/tools.py` | Tool functions (must be `async` for Temporal compatibility) | -| `project/run_worker.py` | Worker boot script (separate process) | -| `tests/test_agent.py` | End-to-end test verifying tool round-trips | -| `manifest.yaml` | Sets `temporal.enabled: true` and declares workflow + queue name | - -## Running Locally - -You'll need three terminals open (this is the price of Temporal): - -```bash -# Terminal 1 — backend services (separate repo) -cd ~/scale-agentex/agentex -make dev # brings up Temporal, Redis, Postgres, Agentex API - -# Terminal 2 — this tutorial (ACP server + Temporal worker) -cd ~/scale-agentex-python/examples/tutorials/10_async/10_temporal/110_pydantic_ai -agentex agents run # this also launches the worker process - -# Terminal 3 — tests -cd ~/scale-agentex-python/examples/tutorials/10_async/10_temporal/110_pydantic_ai -uv run pytest tests/test_agent.py -v -``` - -Watch the Temporal UI at http://localhost:8233 — you'll see workflow executions, signal events, and one activity per LLM call + one per tool call. - -## Sync vs Async vs Temporal — How the code differs - -| Concern | Sync (040) | Async base (110) | Temporal (this one) | -|---|---|---|---| -| `project/acp.py` | `@acp.on_message_send` yields events | `@acp.on_task_event_send` pushes to Redis | **No handlers** — `FastACP.create(type="temporal", ...)` | -| Where the agent runs | In the ACP HTTP process | In the ACP HTTP process | In a separate worker process | -| Durability | Ephemeral — request-scoped | Ephemeral — process-scoped | **Durable** — survives worker restarts via Temporal replay | -| Per-call retries | None | None | Each model + tool call automatically retried by Temporal | -| Code we add | — | `acp.py` handler | `workflow.py`, `run_worker.py`, wrap agent in `TemporalAgent` | - -## Notes - -- Multi-turn conversation memory is not wired here. Workflow state (`self._turn_number`) is durable, but message history isn't currently threaded into `temporal_agent.run(..., message_history=...)`. To add: load via `adk.messages.list(task_id=...)` inside the signal handler and pass through. -- Reasoning/thinking tokens are not exercised by `gpt-4o-mini`. Swap to a reasoning-capable model to exercise that branch end-to-end. -- Tools must be `async` (Pydantic AI's Temporal integration requires it — sync tools would run in threads, breaking Temporal's determinism guarantees). +- 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 index 15d00076f..7ca454b05 100644 --- a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/manifest.yaml +++ b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/manifest.yaml @@ -18,7 +18,7 @@ local_development: agent: acp_type: async name: at110-pydantic-ai - description: A Temporal-backed Pydantic AI agent with tool calling and Redis streaming + description: A Temporal-backed Pydantic AI harness test agent using the unified emitter surface temporal: enabled: true @@ -42,8 +42,6 @@ agent: - env_var_name: SGP_CLIENT_BASE_URL secret_name: sgp-client-base-url secret_key: url - # env: - # OPENAI_BASE_URL: "https://your-litellm-proxy/v1" deployment: image: @@ -53,7 +51,7 @@ deployment: global: agent: name: "at110-pydantic-ai" - description: "A Temporal-backed Pydantic AI agent" + description: "A Temporal-backed Pydantic AI harness test agent using the unified emitter surface" replicaCount: 1 resources: requests: 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 index dacb45ad6..c142dcf70 100644 --- 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 @@ -1,7 +1,7 @@ -"""ACP server for the Temporal Pydantic AI tutorial. +"""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: +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) 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 index a33a317cc..4e59688ce 100644 --- 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 @@ -1,18 +1,20 @@ -"""Pydantic AI agent definition for the Temporal tutorial. +"""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 code stays -deterministic; the non-deterministic work (LLM HTTP calls, tool execution) -moves into recorded activities. - -Streaming back to Agentex happens via ``event_stream_handler``, which -receives Pydantic AI ``AgentStreamEvent``s from inside the model activity -and forwards them to Redis using our existing ``stream_pydantic_ai_events`` -helper. The ``task_id`` is threaded into the handler via ``deps``. +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 @@ -26,10 +28,10 @@ from pydantic_ai.durable_exec.temporal import TemporalAgent from project.tools import get_weather -from agentex.lib.adk import ( - stream_pydantic_ai_events, - create_pydantic_ai_tracing_handler, -) +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. @@ -48,13 +50,13 @@ 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. + 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. + # 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 @@ -77,32 +79,33 @@ 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. + """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 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). + 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. """ - tracing_handler = create_pydantic_ai_tracing_handler( + 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, - task_id=run_context.deps.task_id, - ) - await stream_pydantic_ai_events( - events, - run_context.deps.task_id, - tracing_handler=tracing_handler, ) + 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. +# Construct the durable agent at module load time so that the PydanticAIPlugin +# can auto-discover its activities via the workflow's ``__pydantic_ai_agents__`` +# attribute. base_agent = _build_base_agent() temporal_agent: TemporalAgent[TaskDeps, str] = TemporalAgent( base_agent, - name="at110_pydantic_ai_agent", + 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 index e54c9d1dc..4b4d43d19 100644 --- 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 @@ -1,18 +1,18 @@ -"""Temporal worker for the Pydantic AI tutorial. +"""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. +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. +The ``PydanticAIPlugin`` reads ``__pydantic_ai_agents__`` off the workflow class +and registers every model/tool activity the TemporalAgent needs — so we don't +have to enumerate activities by hand here. """ import asyncio from pydantic_ai.durable_exec.temporal import PydanticAIPlugin -from project.workflow import At110PydanticAiWorkflow +from 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 @@ -31,8 +31,8 @@ async def main(): 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__. + # 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()], @@ -40,7 +40,7 @@ async def main(): await worker.run( activities=get_all_activities(), - workflow=At110PydanticAiWorkflow, + workflow=HarnessPydanticAiWorkflow, ) 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 index 75640fcb7..bbd6c5200 100644 --- 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 @@ -1,9 +1,8 @@ -"""Tool definitions for the Temporal Pydantic AI agent. +"""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 in the -Temporal UI. +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 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 index bb07ac818..9a01be7de 100644 --- 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 @@ -1,16 +1,16 @@ -"""Temporal workflow for the Pydantic AI tutorial. +"""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`` -pushes streaming deltas to Redis while the model activity runs. +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 tutorial we don't need an -external ``adk.state`` round-trip — the message list survives crashes -because Temporal replays activity results that produced it. +(``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 @@ -56,14 +56,14 @@ @workflow.defn(name=environment_variables.WORKFLOW_NAME) -class At110PydanticAiWorkflow(BaseWorkflow): +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``. + ``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] @@ -74,8 +74,8 @@ def __init__(self): 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. + # 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) @@ -93,17 +93,10 @@ async def on_task_event_send(self, params: SendEventParams) -> None: name=f"Turn {self._turn_number}", input={"message": params.event.content.content}, ) as span: - # temporal_agent.run() is the magic line. From the outside it - # looks like a regular async call. Internally it schedules: - # 1. A model activity (LLM HTTP call recorded by Temporal) - # 2. For each tool the model invokes, a tool activity - # 3. Each activity is retried, observable, and durable - # While the model activity runs, the event_stream_handler on - # temporal_agent pushes deltas to Redis so the UI sees tokens. - # - # Passing ``message_history`` makes the run remember prior turns: - # without it the agent would respond to each user message as if - # it had never seen the conversation before. + # 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, @@ -112,8 +105,8 @@ async def on_task_event_send(self, params: SendEventParams) -> None: 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. + # 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} 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 index 9f47733c0..2f308f2a1 100644 --- a/examples/tutorials/10_async/10_temporal/110_pydantic_ai/pyproject.toml +++ b/examples/tutorials/10_async/10_temporal/110_pydantic_ai/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "at110-pydantic-ai" version = "0.1.0" -description = "A Temporal-backed Pydantic AI agent with tool calling and Redis streaming" +description = "A Temporal-backed Pydantic AI harness test agent using the unified emitter surface" readme = "README.md" requires-python = ">=3.12" dependencies = [ 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 index d01276ab8..974cddcc0 100644 --- 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 @@ -1,9 +1,10 @@ -"""Tests for the Temporal Pydantic AI agent. +"""Live tests for the Temporal Pydantic AI agent. -This test suite validates: -- The agent responds to a basic message -- Tool calls are visible in the message history (proving each tool call - ran as its own Temporal activity) +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) @@ -16,10 +17,7 @@ import pytest import pytest_asyncio -from test_utils.async_utils import ( - poll_messages, - send_event_and_poll_yielding, -) +from test_utils.async_utils import poll_messages, send_event_and_poll_yielding from agentex import AsyncAgentex from agentex.types.task_message import TaskMessage @@ -51,14 +49,12 @@ async def agent_id(client, agent_name): class TestNonStreamingEvents: - """Test that the Temporal-backed Pydantic AI agent responds and uses tools.""" + """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_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) task = task_response.result assert task is not None @@ -71,11 +67,7 @@ async def test_send_event_and_poll(self, client: AsyncAgentex, agent_id: str): sleep_interval=1.0, ): assert isinstance(message, TaskMessage) - if ( - message.content - and message.content.type == "text" - and message.content.author == "agent" - ): + 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" @@ -101,11 +93,7 @@ async def test_send_event_and_poll(self, client: AsyncAgentex, agent_id: str): 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" - ): + 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: @@ -115,9 +103,7 @@ async def test_send_event_and_poll(self, client: AsyncAgentex, agent_id: str): 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 - ) + 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" diff --git a/examples/tutorials/10_async/00_base/130_harness_openai/.dockerignore b/examples/tutorials/10_async/10_temporal/120_openai_agents/.dockerignore similarity index 100% rename from examples/tutorials/10_async/00_base/130_harness_openai/.dockerignore rename to examples/tutorials/10_async/10_temporal/120_openai_agents/.dockerignore diff --git a/examples/tutorials/10_async/10_temporal/harness_langgraph/Dockerfile b/examples/tutorials/10_async/10_temporal/120_openai_agents/Dockerfile similarity index 65% rename from examples/tutorials/10_async/10_temporal/harness_langgraph/Dockerfile rename to examples/tutorials/10_async/10_temporal/120_openai_agents/Dockerfile index f6c9fb59b..700f56cea 100644 --- a/examples/tutorials/10_async/10_temporal/harness_langgraph/Dockerfile +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents/Dockerfile @@ -22,20 +22,20 @@ RUN uv pip install --system --upgrade pip setuptools wheel ENV UV_HTTP_TIMEOUT=1000 -COPY 10_async/10_temporal/harness_langgraph/pyproject.toml /app/harness_langgraph/pyproject.toml -COPY 10_async/10_temporal/harness_langgraph/README.md /app/harness_langgraph/README.md +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/harness_langgraph +WORKDIR /app/120_openai_agents -COPY 10_async/10_temporal/harness_langgraph/project /app/harness_langgraph/project -COPY 10_async/10_temporal/harness_langgraph/tests /app/harness_langgraph/tests +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=at-harness-langgraph +ENV AGENT_NAME=at120-openai-agents CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_async/10_temporal/140_harness_openai/README.md b/examples/tutorials/10_async/10_temporal/120_openai_agents/README.md similarity index 94% rename from examples/tutorials/10_async/10_temporal/140_harness_openai/README.md rename to examples/tutorials/10_async/10_temporal/120_openai_agents/README.md index 0415ae225..4db26d0a1 100644 --- a/examples/tutorials/10_async/10_temporal/140_harness_openai/README.md +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents/README.md @@ -9,7 +9,7 @@ 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 (`060_harness_openai`) and async (`130_harness_openai`) variants, and +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). diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/environments.yaml b/examples/tutorials/10_async/10_temporal/120_openai_agents/environments.yaml similarity index 100% rename from examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/environments.yaml rename to examples/tutorials/10_async/10_temporal/120_openai_agents/environments.yaml diff --git a/examples/tutorials/10_async/10_temporal/140_harness_openai/manifest.yaml b/examples/tutorials/10_async/10_temporal/120_openai_agents/manifest.yaml similarity index 78% rename from examples/tutorials/10_async/10_temporal/140_harness_openai/manifest.yaml rename to examples/tutorials/10_async/10_temporal/120_openai_agents/manifest.yaml index 64a943438..4b59db442 100644 --- a/examples/tutorials/10_async/10_temporal/140_harness_openai/manifest.yaml +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents/manifest.yaml @@ -2,10 +2,10 @@ build: context: root: ../../../ include_paths: - - 10_async/10_temporal/140_harness_openai + - 10_async/10_temporal/120_openai_agents - test_utils - dockerfile: 10_async/10_temporal/140_harness_openai/Dockerfile - dockerignore: 10_async/10_temporal/140_harness_openai/.dockerignore + dockerfile: 10_async/10_temporal/120_openai_agents/Dockerfile + dockerignore: 10_async/10_temporal/120_openai_agents/.dockerignore local_development: agent: @@ -17,14 +17,14 @@ local_development: agent: acp_type: async - name: at140-harness-openai + name: at120-openai-agents description: A Temporal-backed OpenAI Agents SDK agent on the unified harness surface temporal: enabled: true workflows: - - name: at140-harness-openai - queue_name: at140_harness_openai_queue + - name: at120-openai-agents + queue_name: at120_openai_agents_queue credentials: - env_var_name: REDIS_URL @@ -50,7 +50,7 @@ deployment: global: agent: - name: "at140-harness-openai" + name: "at120-openai-agents" description: "A Temporal-backed OpenAI Agents SDK agent on the unified harness surface" replicaCount: 1 resources: diff --git a/examples/tutorials/00_sync/harness_pydantic_ai/project/__init__.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/__init__.py similarity index 100% rename from examples/tutorials/00_sync/harness_pydantic_ai/project/__init__.py rename to examples/tutorials/10_async/10_temporal/120_openai_agents/project/__init__.py diff --git a/examples/tutorials/10_async/10_temporal/140_harness_openai/project/acp.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/acp.py similarity index 100% rename from examples/tutorials/10_async/10_temporal/140_harness_openai/project/acp.py rename to examples/tutorials/10_async/10_temporal/120_openai_agents/project/acp.py diff --git a/examples/tutorials/10_async/10_temporal/140_harness_openai/project/activities.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/activities.py similarity index 92% rename from examples/tutorials/10_async/10_temporal/140_harness_openai/project/activities.py rename to examples/tutorials/10_async/10_temporal/120_openai_agents/project/activities.py index a70ee0c5d..2a8a773c4 100644 --- a/examples/tutorials/10_async/10_temporal/140_harness_openai/project/activities.py +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/activities.py @@ -25,7 +25,7 @@ logger = make_logger(__name__) -RUN_HARNESS_AGENT_ACTIVITY = "run_harness_openai_agent" +RUN_AGENT_ACTIVITY = "run_openai_agent" class RunHarnessAgentParams(BaseModel): @@ -51,8 +51,8 @@ class RunHarnessAgentResult(BaseModel): class HarnessActivities: """Hosts the harness-backed OpenAI agent activity.""" - @activity.defn(name=RUN_HARNESS_AGENT_ACTIVITY) - async def run_harness_openai_agent(self, params: RunHarnessAgentParams) -> RunHarnessAgentResult: + @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 diff --git a/examples/tutorials/10_async/10_temporal/140_harness_openai/project/agent.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/agent.py similarity index 100% rename from examples/tutorials/10_async/10_temporal/140_harness_openai/project/agent.py rename to examples/tutorials/10_async/10_temporal/120_openai_agents/project/agent.py diff --git a/examples/tutorials/10_async/10_temporal/140_harness_openai/project/run_worker.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/run_worker.py similarity index 91% rename from examples/tutorials/10_async/10_temporal/140_harness_openai/project/run_worker.py rename to examples/tutorials/10_async/10_temporal/120_openai_agents/project/run_worker.py index 69586a395..b82ee0f50 100644 --- a/examples/tutorials/10_async/10_temporal/140_harness_openai/project/run_worker.py +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/run_worker.py @@ -2,7 +2,7 @@ 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_harness_openai_agent``), and the workflow. +(``HarnessActivities.run_openai_agent``), and the workflow. """ import asyncio @@ -28,7 +28,7 @@ async def main(): harness_activities = HarnessActivities() all_activities = [ - harness_activities.run_harness_openai_agent, + harness_activities.run_openai_agent, *get_all_activities(), ] diff --git a/examples/tutorials/10_async/10_temporal/140_harness_openai/project/tools.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/tools.py similarity index 100% rename from examples/tutorials/10_async/10_temporal/140_harness_openai/project/tools.py rename to examples/tutorials/10_async/10_temporal/120_openai_agents/project/tools.py diff --git a/examples/tutorials/10_async/10_temporal/140_harness_openai/project/workflow.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/workflow.py similarity index 97% rename from examples/tutorials/10_async/10_temporal/140_harness_openai/project/workflow.py rename to examples/tutorials/10_async/10_temporal/120_openai_agents/project/workflow.py index 69ad7b365..566bd93b6 100644 --- a/examples/tutorials/10_async/10_temporal/140_harness_openai/project/workflow.py +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/workflow.py @@ -1,7 +1,7 @@ """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_harness_openai_agent`` (see +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``). @@ -18,7 +18,7 @@ from agentex.lib import adk from project.activities import ( - RUN_HARNESS_AGENT_ACTIVITY, + RUN_AGENT_ACTIVITY, RunHarnessAgentParams, RunHarnessAgentResult, ) @@ -77,7 +77,7 @@ async def on_task_event_send(self, params: SendEventParams) -> None: input={"message": params.event.content.content}, ) as span: turn_result = await workflow.execute_activity( - RUN_HARNESS_AGENT_ACTIVITY, + RUN_AGENT_ACTIVITY, RunHarnessAgentParams( task_id=params.task.id, user_message=params.event.content.content, diff --git a/examples/tutorials/10_async/10_temporal/140_harness_openai/pyproject.toml b/examples/tutorials/10_async/10_temporal/120_openai_agents/pyproject.toml similarity index 95% rename from examples/tutorials/10_async/10_temporal/140_harness_openai/pyproject.toml rename to examples/tutorials/10_async/10_temporal/120_openai_agents/pyproject.toml index 5bf53f6be..e6c77fae3 100644 --- a/examples/tutorials/10_async/10_temporal/140_harness_openai/pyproject.toml +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "at140-harness-openai" +name = "at120-openai-agents" version = "0.1.0" description = "A Temporal-backed OpenAI Agents SDK agent on the unified harness surface" readme = "README.md" diff --git a/examples/tutorials/10_async/10_temporal/140_harness_openai/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/tests/test_agent.py similarity index 100% rename from examples/tutorials/10_async/10_temporal/140_harness_openai/tests/test_agent.py rename to examples/tutorials/10_async/10_temporal/120_openai_agents/tests/test_agent.py diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/.dockerignore b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/.dockerignore deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/.dockerignore +++ /dev/null @@ -1,43 +0,0 @@ -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# Environments -.env** -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# IDE -.idea/ -.vscode/ -*.swp -*.swo - -# Git -.git -.gitignore - -# Misc -.DS_Store diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/Dockerfile b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/Dockerfile deleted file mode 100644 index d4927d0ce..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/Dockerfile +++ /dev/null @@ -1,62 +0,0 @@ -# syntax=docker/dockerfile:1.3 -FROM python:3.12-slim -COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - htop \ - vim \ - curl \ - tar \ - python3-dev \ - postgresql-client \ - build-essential \ - libpq-dev \ - gcc \ - cmake \ - netcat-openbsd \ - nodejs \ - npm \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/** - -# Install tctl (Temporal CLI) -RUN curl -L https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_arm64.tar.gz -o /tmp/tctl.tar.gz && \ - tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ - chmod +x /usr/local/bin/tctl && \ - rm /tmp/tctl.tar.gz - -RUN uv pip install --system --upgrade pip setuptools wheel - -ENV UV_HTTP_TIMEOUT=1000 - -# Copy pyproject.toml and README.md to install dependencies -COPY 10_async/10_temporal/120_openai_agents_local_sandbox/pyproject.toml /app/120_openai_agents_local_sandbox/pyproject.toml -COPY 10_async/10_temporal/120_openai_agents_local_sandbox/README.md /app/120_openai_agents_local_sandbox/README.md - -WORKDIR /app/120_openai_agents_local_sandbox - -# Copy the project code -COPY 10_async/10_temporal/120_openai_agents_local_sandbox/project /app/120_openai_agents_local_sandbox/project - -# Copy the test files -COPY 10_async/10_temporal/120_openai_agents_local_sandbox/tests /app/120_openai_agents_local_sandbox/tests - -# Copy shared test utilities -COPY test_utils /app/test_utils - -# Install the required Python packages with dev dependencies -RUN uv pip install --system .[dev] - -WORKDIR /app/120_openai_agents_local_sandbox - -ENV PYTHONPATH=/app - -# Set test environment variables -ENV AGENT_NAME=at120-openai-agents-local-sandbox - -# Run the ACP server using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] - -# When we deploy the worker, we will replace the CMD with the following -# CMD ["python", "-m", "run_worker"] diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/README.md b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/README.md deleted file mode 100644 index 161bc43da..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/README.md +++ /dev/null @@ -1,130 +0,0 @@ -# Tutorial 120: Temporal OpenAI Agents SDK with a Local Sandbox - -This tutorial demonstrates running an [OpenAI Agents SDK](https://developers.openai.com/api/docs/guides/agents) -`SandboxAgent` inside a **Temporal** workflow, backed by the **local** -(`unix_local`) sandbox. - -The agent is a "local sandbox assistant": it answers questions by actually running -real shell commands (e.g. `python3 --version`, `ls`, `python3 -c "..."`) instead of -guessing. Because it runs inside Temporal, the sandbox tool calls become durable, -retried, and observable activities. - -This mirrors the canonical OpenAI Agents SDK Temporal example -(`060_open_ai_agents_sdk_hello_world`) and the tools example -(`070_open_ai_agents_sdk_tools`). The new piece is the **Temporal sandbox bridge**. - -## Key Concepts - -### Temporal ACP -The Temporal ACP model (`acp_type: async`, `temporal.enabled: true`) maps task -lifecycle to a Temporal workflow: -- `@workflow.run` (`on_task_create`) keeps the conversation alive. -- `@workflow.signal(name=SignalName.RECEIVE_EVENT)` (`on_task_event_send`) handles - each user message. - -No ACP handlers are registered by hand — the `TemporalACPConfig` wires them to the -workflow automatically. - -### Streaming (Interceptor + Model Provider + Hooks) -Real-time streaming uses STANDARD Temporal components — no forked plugin: -- **`ContextInterceptor`** threads `task_id` through activity headers. The workflow - sets `self._task_id` so the interceptor can read it. -- **`TemporalStreamingModelProvider`** returns a model that streams tokens to Redis - in real time while still returning the complete response to Temporal for - determinism / replay safety. -- **`TemporalStreamingHooks`** creates the lifecycle messages (tool request / - response, etc.) in the database. - -The `stream_lifecycle_content` activity must be registered on the worker alongside -`get_all_activities()`. - -### The Temporal sandbox bridge (`UnixLocalSandboxClient`) -The sandbox client is registered ON THE WORKER (and the ACP) via the standard -plugin: - -```python -from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient -from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, SandboxClientProvider - -OpenAIAgentsPlugin( - model_provider=TemporalStreamingModelProvider(), - sandbox_clients=[SandboxClientProvider("local", UnixLocalSandboxClient())], -) -``` - -Inside the workflow, the run is pointed at that backend by name: - -```python -from temporalio.contrib.openai_agents.workflow import temporal_sandbox_client -from agents.sandbox import SandboxAgent, SandboxRunConfig -from agents.run_config import RunConfig -from agents.sandbox.snapshot import NoopSnapshotSpec -from agents.sandbox.capabilities import Shell -from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClientOptions - -agent = SandboxAgent( - name="Local Sandbox Assistant", - model="gpt-4o-mini", - instructions="...use the shell tools to actually run commands...", - capabilities=[Shell()], -) -run_config = RunConfig( - sandbox=SandboxRunConfig( - client=temporal_sandbox_client("local"), - options=UnixLocalSandboxClientOptions(), - snapshot=NoopSnapshotSpec(), # skip the per-turn workspace snapshot - ) -) -result = await Runner.run( - agent, self._state.input_list, run_config=run_config, - hooks=TemporalStreamingHooks(task_id=params.task.id), -) -``` - -`temporal_sandbox_client("local")` resolves the worker-registered client, so the -sandbox shell tool calls run as Temporal activities (durable + observable in the -Temporal UI). - -## Two important lessons - -1. **Don't double-post the assistant message.** The `TemporalStreamingModelProvider` - already streams AND persists the assistant's response. If you also call - `adk.messages.create(...)` after `Runner.run`, the answer shows up twice. We only - persist conversation state for the next turn via `result.to_input_list()`. -2. **Use `NoopSnapshotSpec()`.** Without it, the sandbox tries to take a per-turn - workspace snapshot, and stopping the sandbox can raise - `WorkspaceArchiveReadError`. `NoopSnapshotSpec()` skips that snapshot. - -## Files - -| File | Description | -|------|-------------| -| `project/acp.py` | Temporal ACP server (plugin + sandbox client + interceptor) | -| `project/run_worker.py` | Temporal worker (registers workflow, activities, plugin, sandbox client) | -| `project/workflow.py` | `BaseWorkflow` that runs the `SandboxAgent` against the local sandbox | -| `tests/test_agent.py` | Integration tests (polling pattern) | -| `manifest.yaml` | Agent configuration (temporal enabled) | -| `environments.yaml` | Per-environment deployment overrides | - -## Running Locally - -```bash -# From this directory -agentex agents run -``` - -Set `OPENAI_API_KEY` (or `LITELLM_API_KEY` if you're behind the Scale LiteLLM -gateway) in your environment or in a `.env` file in `project/` so the agent can call -the model. - -## Running Tests - -```bash -pytest tests/test_agent.py -v -``` - -## Further Reading - -- OpenAI Agents SDK guide: https://developers.openai.com/api/docs/guides/agents -- The async (non-Temporal) variant: `10_async/00_base/120_openai_agents_local_sandbox` -- The canonical OpenAI Agents SDK Temporal example: `10_async/10_temporal/060_open_ai_agents_sdk_hello_world` diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/manifest.yaml b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/manifest.yaml deleted file mode 100644 index 86ac89288..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/manifest.yaml +++ /dev/null @@ -1,111 +0,0 @@ -# Agent Manifest Configuration -# --------------------------- -# This file defines how your agent should be built and deployed. - -# Build Configuration -# ------------------ -build: - context: - # Root directory for the build context - root: ../../../ # Up to tutorials level to include test_utils - - # Paths to include in the Docker build context - include_paths: - - 10_async/10_temporal/120_openai_agents_local_sandbox - - test_utils - - # Path to your agent's Dockerfile (relative to the root directory) - dockerfile: 10_async/10_temporal/120_openai_agents_local_sandbox/Dockerfile - - # Path to your agent's .dockerignore - dockerignore: 10_async/10_temporal/120_openai_agents_local_sandbox/.dockerignore - - -# Local Development Configuration -# ----------------------------- -local_development: - agent: - port: 8000 # Port where your local ACP server is running - host_address: host.docker.internal # Host address for Docker networking - - # File paths for local development (relative to this manifest.yaml) - paths: - # Path to ACP server file - acp: project/acp.py - # Path to temporal worker file - worker: project/run_worker.py - - -# Agent Configuration -# ----------------- -agent: - # Type of agent - either sync or async - acp_type: async - - # Unique name for your agent - name: at120-openai-agents-local-sandbox - - # Description of what your agent does - description: A Temporal OpenAI Agents SDK agent using a local (unix_local) sandbox - - # Temporal workflow configuration - temporal: - enabled: true - workflows: - # Name of the workflow class (must match the @workflow.defn name in workflow.py) - - name: at120-openai-agents-local-sandbox - - # Queue name for task distribution - queue_name: at120_openai_agents_local_sandbox_queue - - # Credentials mapping (maps Kubernetes secrets to environment variables) - credentials: - - env_var_name: OPENAI_API_KEY - secret_name: openai-api-key - secret_key: api-key - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - - env_var_name: SGP_API_KEY - secret_name: sgp-api-key - secret_key: api-key - - env_var_name: SGP_ACCOUNT_ID - secret_name: sgp-account-id - secret_key: account-id - - env_var_name: SGP_CLIENT_BASE_URL - secret_name: sgp-client-base-url - secret_key: url - - # Environment variables for running locally and for deployment - env: - OPENAI_AGENTS_DISABLE_TRACING: "1" - - -# Deployment Configuration -# ----------------------- -deployment: - # Container image configuration - image: - repository: "" # Update with your container registry - tag: "latest" # Default tag, should be versioned in production - - imagePullSecrets: - - name: my-registry-secret # Update with your image pull secret name - - # Global deployment settings that apply to all clusters - global: - agent: - name: "at120-openai-agents-local-sandbox" - description: "A Temporal OpenAI Agents SDK agent using a local (unix_local) sandbox" - - # Default replica count - replicaCount: 1 - - # Default resource requirements - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/__init__.py b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/acp.py b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/acp.py deleted file mode 100644 index 196e1e7cd..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/acp.py +++ /dev/null @@ -1,83 +0,0 @@ -import os -import sys - -from temporalio.contrib.openai_agents import ( - OpenAIAgentsPlugin, - SandboxClientProvider, -) -from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient - -# === DEBUG SETUP (AgentEx CLI Debug Support) === -if os.getenv("AGENTEX_DEBUG_ENABLED") == "true": - try: - import debugpy - debug_port = int(os.getenv("AGENTEX_DEBUG_PORT", "5679")) - debug_type = os.getenv("AGENTEX_DEBUG_TYPE", "acp") - wait_for_attach = os.getenv("AGENTEX_DEBUG_WAIT_FOR_ATTACH", "false").lower() == "true" - - # Configure debugpy - debugpy.configure(subProcess=False) - debugpy.listen(debug_port) - - print(f"🐛 [{debug_type.upper()}] Debug server listening on port {debug_port}") - - if wait_for_attach: - print(f"⏳ [{debug_type.upper()}] Waiting for debugger to attach...") - debugpy.wait_for_client() - print(f"✅ [{debug_type.upper()}] Debugger attached!") - else: - print(f"📡 [{debug_type.upper()}] Ready for debugger attachment") - - except ImportError: - print("❌ debugpy not available. Install with: pip install debugpy") - sys.exit(1) - except Exception as e: - print(f"❌ Debug setup failed: {e}") - sys.exit(1) -# === END DEBUG SETUP === - -from agentex.lib.types.fastacp import TemporalACPConfig -from agentex.lib.sdk.fastacp.fastacp import FastACP -from agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model import ( - TemporalStreamingModelProvider, -) -from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ( - ContextInterceptor, -) - -context_interceptor = ContextInterceptor() -temporal_streaming_model_provider = TemporalStreamingModelProvider() - -# Create the ACP server. We register the STANDARD OpenAIAgentsPlugin with: -# - the streaming model provider (real-time token streaming + persistence) -# - the LOCAL sandbox backend, registered under the name "local" so the -# workflow can resolve it via ``temporal_sandbox_client("local")`` -# plus the ContextInterceptor that threads task_id through activity headers. -acp = FastACP.create( - acp_type="async", - config=TemporalACPConfig( - # When deployed to the cluster, the Temporal address is set automatically. - # For local development, we set the address manually to talk to the local - # Temporal service set up via docker compose. - type="temporal", - temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), - plugins=[ - OpenAIAgentsPlugin( - model_provider=temporal_streaming_model_provider, - sandbox_clients=[ - SandboxClientProvider("local", UnixLocalSandboxClient()), - ], - ) - ], - interceptors=[context_interceptor], - ), -) - - -# Notice that we don't need to register any handlers when we use type="temporal". -# These handlers are automatically registered when the ACP is created: -# -# @acp.on_task_create -> the workflow method decorated with @workflow.run -# @acp.on_task_event_send -> the workflow method decorated with -# @workflow.signal(name=SignalName.RECEIVE_EVENT) -# @acp.on_task_cancel -> handled by the temporal client (cancels the workflow) diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/run_worker.py b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/run_worker.py deleted file mode 100644 index a2b7bdf6b..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/run_worker.py +++ /dev/null @@ -1,80 +0,0 @@ -import asyncio - -from temporalio.contrib.openai_agents import ( - OpenAIAgentsPlugin, - SandboxClientProvider, -) -from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient - -from project.workflow import At120OpenaiAgentsLocalSandboxWorkflow -from agentex.lib.utils.debug import setup_debug_if_enabled -from agentex.lib.utils.logging import make_logger -from agentex.lib.environment_variables import EnvironmentVariables -from agentex.lib.core.temporal.activities import get_all_activities -from agentex.lib.core.temporal.workers.worker import AgentexWorker -from agentex.lib.core.temporal.plugins.openai_agents.hooks.activities import ( - stream_lifecycle_content, -) -from agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model import ( - TemporalStreamingModelProvider, -) -from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ( - ContextInterceptor, -) - -environment_variables = EnvironmentVariables.refresh() - -logger = make_logger(__name__) - - -async def main(): - # Setup debug mode if enabled - setup_debug_if_enabled() - - task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE - if task_queue_name is None: - raise ValueError("WORKFLOW_TASK_QUEUE is not set") - - # Register activities. ``stream_lifecycle_content`` powers the streaming - # lifecycle hooks; the rest are the standard AgentEx activities. - all_activities = get_all_activities() + [stream_lifecycle_content] - - # ============================================================================ - # STREAMING + SANDBOX SETUP - # ============================================================================ - # 1. ContextInterceptor threads task_id through activity headers so the - # streaming model + hooks know which task to stream/persist to. - # 2. TemporalStreamingModelProvider returns a model that streams tokens to - # Redis in real time while still returning the complete response to - # Temporal for determinism / replay safety. - # 3. SandboxClientProvider registers the LOCAL sandbox backend - # (UnixLocalSandboxClient) under the name "local". The workflow resolves - # it at run time via ``temporal_sandbox_client("local")``, so the sandbox - # tool calls run as durable Temporal activities. - # - # We use the STANDARD temporalio.contrib.openai_agents.OpenAIAgentsPlugin — - # no forked plugin needed. - context_interceptor = ContextInterceptor() - temporal_streaming_model_provider = TemporalStreamingModelProvider() - - worker = AgentexWorker( - task_queue=task_queue_name, - plugins=[ - OpenAIAgentsPlugin( - model_provider=temporal_streaming_model_provider, - sandbox_clients=[ - SandboxClientProvider("local", UnixLocalSandboxClient()), - ], - ) - ], - interceptors=[context_interceptor], - ) - - await worker.run( - activities=all_activities, - workflow=At120OpenaiAgentsLocalSandboxWorkflow, - ) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/workflow.py b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/workflow.py deleted file mode 100644 index 45b61b04e..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/workflow.py +++ /dev/null @@ -1,213 +0,0 @@ -"""OpenAI Agents SDK + Temporal: Local Sandbox Tutorial - -This tutorial demonstrates running an OpenAI Agents SDK ``SandboxAgent`` inside a -Temporal workflow, backed by the **local** (``unix_local``) sandbox. The agent is -a "local sandbox assistant": it answers questions by actually running real shell -commands (e.g. ``python3 --version``, ``ls``, ``python3 -c "..."``) instead of -guessing. - -KEY CONCEPTS DEMONSTRATED: -- A ``SandboxAgent`` granted the ``Shell`` capability inside a durable Temporal - workflow. -- The Temporal sandbox bridge: ``temporal_sandbox_client("local")`` resolves to - the ``UnixLocalSandboxClient`` registered on the worker via - ``SandboxClientProvider`` (see ``run_worker.py`` / ``acp.py``). The sandbox tool - calls run as Temporal activities, so they are durable, retried, and observable. -- Real-time streaming + persistence via ``TemporalStreamingModelProvider`` + - ``ContextInterceptor`` (configured on the worker) and ``TemporalStreamingHooks``. - -IMPORTANT LESSONS (applied below): - (a) Do NOT post the assistant message yourself with ``adk.messages.create`` - after ``Runner.run``. The ``TemporalStreamingModelProvider`` already streams - and persists the assistant's response — posting it again would duplicate the - answer in the UI. We only persist conversation state for the next turn via - ``result.to_input_list()``. - (b) Use ``NoopSnapshotSpec()`` so the per-turn workspace snapshot is skipped. - Without it, stopping the sandbox can raise ``WorkspaceArchiveReadError``. -""" - -from __future__ import annotations - -import os -import json - -from agents import Runner -from temporalio import workflow - -from agentex.lib import adk -from agentex.lib.types.acp import SendEventParams, CreateTaskParams -from agentex.lib.types.tracing import SGPTracingProcessorConfig -from agentex.lib.utils.logging import make_logger -from agentex.types.text_content import TextContent -from agentex.lib.utils.model_utils import BaseModel -from agentex.lib.environment_variables import EnvironmentVariables -from agentex.lib.core.temporal.types.workflow import SignalName -from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow -from agentex.lib.core.tracing.tracing_processor_manager import ( - add_tracing_processor_config, -) -from agentex.lib.core.temporal.plugins.openai_agents.hooks.hooks import ( - TemporalStreamingHooks, -) - -# OpenAI Agents SDK sandbox imports. These are safe to import at workflow module -# load time; the actual sandbox client is resolved at run time via -# ``temporal_sandbox_client`` (which maps to the worker-registered backend). -with workflow.unsafe.imports_passed_through(): - from agents.sandbox import SandboxAgent, SandboxRunConfig - from agents.run_config import RunConfig - from agents.sandbox.snapshot import NoopSnapshotSpec - from agents.sandbox.capabilities import Shell - from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClientOptions - from temporalio.contrib.openai_agents.workflow import temporal_sandbox_client - -# Configure tracing processor (optional - only if you have SGP credentials) -add_tracing_processor_config( - SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SGP_API_KEY", ""), - sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), - ) -) - -environment_variables = EnvironmentVariables.refresh() - -if environment_variables.WORKFLOW_NAME is None: - raise ValueError("Environment variable WORKFLOW_NAME is not set") - -if environment_variables.AGENT_NAME is None: - raise ValueError("Environment variable AGENT_NAME is not set") - -logger = make_logger(__name__) - -MODEL_NAME = "gpt-4o-mini" -INSTRUCTIONS = """You are a local sandbox assistant. - -You have access to shell tools that run real commands on the local machine. - -Guidelines: -- ALWAYS use the shell tools to actually run commands — never guess or make up - output. If the user asks for the Python version, run `python3 --version`. If - they ask to list files, run `ls`. If they ask you to compute something, use - `python3 -c "..."`. -- Run the minimal command(s) needed to answer the question. -- Report the real command output back to the user, concisely. -""" - - -class StateModel(BaseModel): - """State model for preserving conversation history across turns.""" - - input_list: list = [] - turn_number: int = 0 - - -@workflow.defn(name=environment_variables.WORKFLOW_NAME) -class At120OpenaiAgentsLocalSandboxWorkflow(BaseWorkflow): - """Long-running Temporal workflow that runs a SandboxAgent against the local sandbox.""" - - def __init__(self): - super().__init__(display_name=environment_variables.AGENT_NAME) - self._complete_task = False - self._state: StateModel | None = None - self._task_id = None - self._trace_id = None - self._parent_span_id = None - - @workflow.signal(name=SignalName.RECEIVE_EVENT) - async def on_task_event_send(self, params: SendEventParams) -> None: - logger.info(f"Received task event: {params.task.id}") - - if self._state is None: - raise ValueError("State is not initialized") - - self._state.turn_number += 1 - - # The ContextInterceptor reads ``self._task_id`` off the workflow - # instance and threads it through activity headers so the streaming - # model + hooks know which task to stream/persist to. - self._task_id = params.task.id - self._trace_id = params.task.id - - # Add the user message to conversation history. - self._state.input_list.append({"role": "user", "content": params.event.content.content}) - - # Echo back the client's message so it shows up in the UI. - await adk.messages.create(task_id=params.task.id, content=params.event.content) - - async with adk.tracing.span( - trace_id=params.task.id, - name=f"Turn {self._state.turn_number}", - input=self._state.model_dump(), - ) as span: - self._parent_span_id = span.id if span else None - - # Build the sandbox agent. The Shell capability becomes real shell - # tools backed by the sandbox client resolved at run time. - agent = SandboxAgent( - name="Local Sandbox Assistant", - model=MODEL_NAME, - instructions=INSTRUCTIONS, - capabilities=[Shell()], - ) - - # Point the run at the LOCAL sandbox backend registered on the worker - # under the name "local". ``temporal_sandbox_client`` resolves that - # registration so the sandbox tool calls execute as Temporal - # activities (durable + observable). - # - # IMPORTANT: ``NoopSnapshotSpec()`` skips the per-turn workspace - # snapshot — otherwise stopping the sandbox can raise - # ``WorkspaceArchiveReadError``. - run_config = RunConfig( - sandbox=SandboxRunConfig( - client=temporal_sandbox_client("local"), - options=UnixLocalSandboxClientOptions(), - snapshot=NoopSnapshotSpec(), - ) - ) - - # TemporalStreamingHooks creates the lifecycle messages (tool - # request/response, etc.) and works with the streaming model - # provider to stream tokens to the UI in real time. - result = await Runner.run( - agent, - self._state.input_list, - run_config=run_config, - hooks=TemporalStreamingHooks(task_id=params.task.id), - max_turns=10, - ) - - # IMPORTANT: We do NOT post the assistant message ourselves here. - # The TemporalStreamingModelProvider already streamed and persisted - # the assistant's response. We only persist conversation state for - # the next turn. - self._state.input_list = result.to_input_list() - - if span: - span.output = self._state.model_dump() - - @workflow.run - async def on_task_create(self, params: CreateTaskParams) -> str: - logger.info(f"Task created: {params.task.id}") - - self._state = StateModel(input_list=[], turn_number=0) - - await adk.messages.create( - task_id=params.task.id, - content=TextContent( - author="agent", - content=( - f"Task initialized with params:\n{json.dumps(params.params, indent=2)}\n" - f"Send me a message and I'll run real shell commands in a local " - f"sandbox (backed by Temporal) to answer." - ), - ), - ) - - await workflow.wait_condition(lambda: self._complete_task, timeout=None) - return "Task completed" - - @workflow.signal - async def complete_task_signal(self) -> None: - logger.info("Received complete_task signal") - self._complete_task = True diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/pyproject.toml b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/pyproject.toml deleted file mode 100644 index 696894e32..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/pyproject.toml +++ /dev/null @@ -1,36 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "at120_openai_agents_local_sandbox" -version = "0.1.0" -description = "A Temporal OpenAI Agents SDK agent using a local (unix_local) sandbox" -requires-python = ">=3.12" -dependencies = [ - "agentex-sdk>=0.6.0", - "openai-agents>=0.14.3,<0.15", - "temporalio>=1.18.2", - "scale-gp", -] - -[project.optional-dependencies] -dev = [ - "pytest", - "pytest-asyncio", - "black", - "isort", - "flake8", - "debugpy>=1.8.15", -] - -[tool.hatch.build.targets.wheel] -packages = ["project"] - -[tool.black] -line-length = 88 -target-version = ['py312'] - -[tool.isort] -profile = "black" -line_length = 88 diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/tests/test_agent.py deleted file mode 100644 index 5e161c061..000000000 --- a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/tests/test_agent.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Tests for the Temporal OpenAI Agents SDK local-sandbox agent. - -This test suite validates that the agent actually runs shell commands in the -LOCAL sandbox (unix_local backend) via the Temporal sandbox bridge, by polling -for the agent's response: -- Ask for the Python version -> response contains "Python 3" -- Ask it to compute 21 * 2 with python3 -> response contains "42" - -To run these tests: -1. Make sure the agent is running (via docker-compose or `agentex agents run`) -2. Set the AGENTEX_API_BASE_URL environment variable if not using default -3. Run: pytest test_agent.py -v - -Configuration: -- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) -- AGENT_NAME: Name of the agent to test (default: at120-openai-agents-local-sandbox) -""" - -import os -import uuid - -import pytest -import pytest_asyncio -from test_utils.async_utils import ( - poll_messages, - send_event_and_poll_yielding, -) - -from agentex import AsyncAgentex -from agentex.types.task_message import TaskMessage -from agentex.types.agent_rpc_params import ParamsCreateTaskRequest - -# Configuration from environment variables -AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") -AGENT_NAME = os.environ.get("AGENT_NAME", "at120-openai-agents-local-sandbox") - - -@pytest_asyncio.fixture -async def client(): - """Create an AsyncAgentex client instance for testing.""" - client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) - yield client - await client.close() - - -@pytest.fixture -def agent_name(): - """Return the agent name for testing.""" - return AGENT_NAME - - -@pytest_asyncio.fixture -async def agent_id(client, agent_name): - """Retrieve the agent ID based on the agent name.""" - agents = await client.agents.list() - for agent in agents: - if agent.name == agent_name: - return agent.id - raise ValueError(f"Agent with name {agent_name} not found.") - - -async def _create_task_and_await_welcome(client: AsyncAgentex, agent_id: str) -> str: - """Create a task and wait for the workflow's welcome message; return the task id.""" - task_response = await client.agents.create_task( - agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex) - ) - task = task_response.result - assert task is not None - - welcome_found = False - async for message in poll_messages( - client=client, - task_id=task.id, - timeout=30, - sleep_interval=1.0, - ): - assert isinstance(message, TaskMessage) - if message.content and message.content.type == "text" and message.content.author == "agent": - welcome_found = True - break - assert welcome_found, "Task creation (welcome) message not found" - return task.id - - -async def _send_and_collect_agent_text( - client: AsyncAgentex, agent_id: str, task_id: str, user_message: str -) -> str: - """Send a user message and accumulate the streamed agent text into a string.""" - final_message = None - async for message in send_event_and_poll_yielding( - client=client, - agent_id=agent_id, - task_id=task_id, - user_message=user_message, - timeout=60, - sleep_interval=1.0, - yield_updates=True, # Get updates as streaming writes chunks - ): - if message.content and message.content.type == "text" and message.content.author == "agent": - final_message = message - if message.streaming_status == "DONE": - break - - assert final_message is not None, "Should have received an agent text message" - assert final_message.content is not None, "Final message should have content" - return final_message.content.content or "" - - -class TestLocalSandboxEvents: - """Test the Temporal local-sandbox OpenAI Agents SDK agent.""" - - @pytest.mark.asyncio - async def test_shell_python_version(self, client: AsyncAgentex, agent_id: str): - """The agent should run `python3 --version` in the local sandbox. - - The sandbox runs on Python 3.12, so the real output contains "Python 3". - """ - task_id = await _create_task_and_await_welcome(client, agent_id) - text = await _send_and_collect_agent_text( - client, - agent_id, - task_id, - "Use your shell to print the Python version on this machine, then " - "tell me what it is.", - ) - assert text, "Expected a non-empty response from the sandbox agent." - assert "Python 3" in text - - @pytest.mark.asyncio - async def test_shell_compute(self, client: AsyncAgentex, agent_id: str): - """The agent should use python3 in the sandbox to compute 21 * 2 == 42.""" - task_id = await _create_task_and_await_welcome(client, agent_id) - text = await _send_and_collect_agent_text( - client, - agent_id, - task_id, - "Use python3 in your shell to compute 21 * 2 and tell me the result.", - ) - assert text, "Expected a non-empty response from the sandbox agent." - assert "42" in text - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/.dockerignore b/examples/tutorials/10_async/10_temporal/130_langgraph/.dockerignore index c4f7a8b4b..c49489471 100644 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/.dockerignore +++ b/examples/tutorials/10_async/10_temporal/130_langgraph/.dockerignore @@ -40,4 +40,4 @@ venv.bak/ .gitignore # Misc -.DS_Store \ No newline at end of file +.DS_Store diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/.env.example b/examples/tutorials/10_async/10_temporal/130_langgraph/.env.example deleted file mode 100644 index ab1a5790f..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/.env.example +++ /dev/null @@ -1,13 +0,0 @@ -# at130-langgraph - Environment Variables -# Copy this file to .env and fill in the values - -# API key for your LLM provider -LITELLM_API_KEY= - -# LLM base URL (optional - override to use a different provider) -# OPENAI_BASE_URL= - -# SGP Configuration (optional - for tracing) -# SGP_API_KEY= -# SGP_ACCOUNT_ID= -# SGP_CLIENT_BASE_URL= \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/README.md b/examples/tutorials/10_async/10_temporal/130_langgraph/README.md index 61ccaf66a..0820f56ab 100644 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/README.md +++ b/examples/tutorials/10_async/10_temporal/130_langgraph/README.md @@ -1,58 +1,49 @@ -# at130-langgraph — AgentEx Temporal + LangGraph +# Tutorial: Temporal LangGraph Agent -A minimal Temporal-backed [LangGraph](https://langchain-ai.github.io/langgraph/) -agent. It uses the official [`temporalio.contrib.langgraph`](https://docs.temporal.io/develop/python/integrations/langgraph) -plugin so each LangGraph node runs as a durable **Temporal activity** (the LLM -`agent` node) or inline in the **workflow** (the `tools` node) — set per node -with `execute_in`. *Temporal is the runtime; LangGraph is the agent framework.* +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. -> The Temporal LangGraph plugin is currently **experimental**. +## Key Concepts -## The graph +### Temporal + LangGraph -``` -START → agent → (tool calls?) → tools → agent - → (no tool calls?) → END -``` - -- `agent` (`execute_in="activity"`): the LLM call — a retried, observable Temporal activity. -- `tools` (`execute_in="workflow"`): runs the tool calls inline in the workflow. +The ``LangGraphPlugin`` from ``temporalio.contrib.langgraph`` turns annotated graph +nodes into Temporal activities or inline workflow callables: -The router and tools are `async` so LangGraph awaits them directly (a sync -callable is offloaded via `run_in_executor`, which Temporal workflows forbid). +- `agent` node: `execute_in="activity"` (durable, retryable LLM call) +- `tools` node: `execute_in="workflow"` (inline, fast tool execution) -## Project structure - -``` -130_langgraph/ -├── project/ -│ ├── acp.py # Thin async ACP server; registers the LangGraphPlugin -│ ├── workflow.py # Runs the graph each turn; keeps multi-turn memory -│ ├── graph.py # LangGraph graph; nodes tagged execute_in activity/workflow -│ └── tools.py # Async tool(s) -└── run_worker.py is project/run_worker.py -``` +### Message surfacing -## Running +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. -```bash -agentex agents run --manifest manifest.yaml -``` +This is the Temporal-specific path. The non-Temporal async/sync channels use +``UnifiedEmitter.auto_send_turn`` / ``UnifiedEmitter.yield_turn`` with +``LangGraphTurn`` instead. -Open the Temporal UI at http://localhost:8080 to watch the workflow and the -`agent` activity execute. Use `dev.ipynb` to create a task and send messages. +## Files -## Adding tools +| 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) | -Define an **async** `@tool` in `project/tools.py` and add it to `TOOLS`. The -model is bound with `TOOLS` and the tool node runs them by name. +## Running Locally -For a fuller version with human-in-the-loop approval and graph-introspection -queries, scaffold the `temporal-langgraph` template via `agentex init`. +```bash +agentex agents run +``` -## Tests +## Running Tests -- `tests/test_graph_temporal.py` — hermetic ReAct-loop test with a stub model, - plus a live end-to-end run through the real Temporal plugin (skipped unless - `LITELLM_API_KEY` is set). -- `tests/test_agent.py` — live integration against a running agent. +```bash +pytest tests/test_agent.py -v +``` diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/dev.ipynb b/examples/tutorials/10_async/10_temporal/130_langgraph/dev.ipynb deleted file mode 100644 index 5320daac7..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/dev.ipynb +++ /dev/null @@ -1,126 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "36834357", - "metadata": {}, - "outputs": [], - "source": [ - "from agentex import Agentex\n", - "\n", - "client = Agentex(base_url=\"http://localhost:5003\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d1c309d6", - "metadata": {}, - "outputs": [], - "source": [ - "AGENT_NAME = \"at130-langgraph\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9f6e6ef0", - "metadata": {}, - "outputs": [], - "source": [ - "# (REQUIRED) Create a new task. For Async agents, you must create a task for messages to be associated with.\n", - "import uuid\n", - "\n", - "rpc_response = client.agents.create_task(\n", - " agent_name=AGENT_NAME,\n", - " params={\n", - " \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n", - " \"params\": {}\n", - " }\n", - ")\n", - "\n", - "task = rpc_response.result\n", - "print(task)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b03b0d37", - "metadata": {}, - "outputs": [], - "source": [ - "# Send an event to the agent\n", - "\n", - "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", - "# - TextContent: A message with just text content \n", - "# - DataContent: A message with JSON-serializable data content\n", - "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", - "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", - "\n", - "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", - "\n", - "rpc_response = client.agents.send_event(\n", - " agent_name=AGENT_NAME,\n", - " params={\n", - " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", - " \"task_id\": task.id,\n", - " }\n", - ")\n", - "\n", - "event = rpc_response.result\n", - "print(event)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a6927cc0", - "metadata": {}, - "outputs": [], - "source": [ - "# Subscribe to the async task messages produced by the agent\n", - "from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n", - "\n", - "task_messages = subscribe_to_async_task_messages(\n", - " client=client,\n", - " task=task, \n", - " only_after_timestamp=event.created_at, \n", - " print_messages=True,\n", - " rich_print=True,\n", - " timeout=5,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4864e354", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": ".venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.9" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/environments.yaml b/examples/tutorials/10_async/10_temporal/130_langgraph/environments.yaml deleted file mode 100644 index d54d8e5ff..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/environments.yaml +++ /dev/null @@ -1,64 +0,0 @@ -# Agent Environment Configuration -# ------------------------------ -# This file defines environment-specific settings for your agent. -# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. - -# ********** EXAMPLE ********** -# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI -# environments: -# dev: -# auth: -# principal: -# user_id: "1234567890" -# user_name: "John Doe" -# user_email: "john.doe@example.com" -# user_role: "admin" -# user_permissions: "read, write, delete" -# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts -# replicas: 3 -# resources: -# requests: -# cpu: "1000m" -# memory: "2Gi" -# limits: -# cpu: "2000m" -# memory: "4Gi" -# env: -# - name: LOG_LEVEL -# value: "DEBUG" -# - name: ENVIRONMENT -# value: "staging" -# -# kubernetes: -# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived -# # namespace and deploy it with in the same namespace that already exists for a separate agent. -# namespace: "team-at130-langgraph" -# ********** END EXAMPLE ********** - -schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI -environments: - dev: - auth: - principal: - user_id: # TODO: Fill in - account_id: # TODO: Fill in - helm_overrides: - # This is used to override the global helm values.yaml file in the agentex-agent helm charts - replicaCount: 2 - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" - temporal-worker: - enabled: true - replicaCount: 2 - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/manifest.yaml b/examples/tutorials/10_async/10_temporal/130_langgraph/manifest.yaml index d1f5960b1..936ebfa68 100644 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/manifest.yaml +++ b/examples/tutorials/10_async/10_temporal/130_langgraph/manifest.yaml @@ -1,20 +1,5 @@ -# Agent Manifest Configuration -# --------------------------- -# This file defines how your agent should be built and deployed. - -# Build Configuration -# ------------------ -# The build config defines what gets packaged into your agent's Docker image. -# This same configuration is used whether building locally or remotely. -# -# When building: -# 1. All files from include_paths are collected into a build context -# 2. The context is filtered by dockerignore rules -# 3. The Dockerfile uses this context to build your agent's image -# 4. The image is pushed to a registry and used to run your agent build: context: - # Build from the tutorials root so shared test_utils are available. root: ../../../ include_paths: - 10_async/10_temporal/130_langgraph @@ -22,107 +7,45 @@ build: dockerfile: 10_async/10_temporal/130_langgraph/Dockerfile dockerignore: 10_async/10_temporal/130_langgraph/.dockerignore - -# Local Development Configuration -# ----------------------------- -# Only used when running the agent locally local_development: agent: - port: 8000 # Port where your local ACP server is running - host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) - - # File paths for local development (relative to this manifest.yaml) + port: 8000 + host_address: host.docker.internal paths: - # Path to ACP server file - # Examples: - # project/acp.py (standard) - # src/server.py (custom structure) - # ../shared/acp.py (shared across projects) - # /absolute/path/acp.py (absolute path) acp: project/acp.py - - # Path to temporal worker file - # Examples: - # project/run_worker.py (standard) - # workers/temporal.py (custom structure) - # ../shared/worker.py (shared across projects) worker: project/run_worker.py - -# Agent Configuration -# ----------------- agent: - # Type of agent - either sync or async acp_type: async - - # Unique name for your agent - # Used for task routing and monitoring name: at130-langgraph + description: "A Temporal-backed LangGraph agent (harness variant) whose nodes run as Temporal activities" - # Description of what your agent does - # Helps with documentation and discovery - description: "A Temporal-backed LangGraph agent whose nodes run as Temporal activities" - - # Temporal workflow configuration - # This enables your agent to run as a Temporal workflow for long-running tasks temporal: enabled: true workflows: - # Name of the workflow class - # Must match the @workflow.defn name in your workflow.py - name: at130-langgraph - - # Queue name for task distribution - # Used by Temporal to route tasks to your agent - # Convention: _task_queue queue_name: at130_langgraph_queue - # Optional: Health check port for temporal worker - # Defaults to 80 if not specified - # health_check_port: 80 - - # Optional: Credentials mapping - # Maps Kubernetes secrets to environment variables - # Common credentials include: credentials: - env_var_name: REDIS_URL secret_name: redis-url-secret secret_key: url - # - env_var_name: LITELLM_API_KEY - # secret_name: litellm-api-key - # secret_key: api-key - - # Optional: Set Environment variables for running your agent locally as well - # as for deployment later on - env: {} - # LITELLM_API_KEY: "" - # OPENAI_BASE_URL: "" - # OPENAI_ORG_ID: "" + env: {} -# 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 + repository: "" + tag: "latest" - imagePullSecrets: [] # Update with your image pull secret name - # - name: my-registry-secret + imagePullSecrets: [] - # 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 + memory: "2Gi" 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 index c01f8831c..7af9c5e68 100644 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/project/acp.py +++ b/examples/tutorials/10_async/10_temporal/130_langgraph/project/acp.py @@ -1,19 +1,13 @@ -"""ACP server for the Temporal LangGraph agent. +"""ACP server for the Temporal harness LangGraph agent. -This file is intentionally thin. When ``acp_type="async"`` is combined with -``TemporalACPConfig(type="temporal", ...)``, FastACP auto-wires: +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. - 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. +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 @@ -33,10 +27,8 @@ acp = FastACP.create( acp_type="async", config=TemporalACPConfig( - # When deployed to the cluster, the Temporal address is set automatically. - # Locally we point at the Temporal service from docker compose. type="temporal", temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), plugins=[LangGraphPlugin(graphs={GRAPH_NAME: build_graph()})], ), -) \ No newline at end of file +) diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/project/graph.py b/examples/tutorials/10_async/10_temporal/130_langgraph/project/graph.py index 0589aa9ba..7adba3ae4 100644 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/project/graph.py +++ b/examples/tutorials/10_async/10_temporal/130_langgraph/project/graph.py @@ -1,24 +1,9 @@ """LangGraph graph for at130-langgraph — nodes run as Temporal activities. -The ``temporalio.contrib.langgraph`` plugin runs each node where its -``execute_in`` metadata says: the LLM ``agent`` node as a durable Temporal -**activity**, the ``tools`` node inline in the **workflow**. - - START → agent → (tool calls?) → tools → agent - → (no tool calls?) → END - -The router and tools are ``async`` so LangGraph awaits them directly — a sync -callable would be offloaded via ``run_in_executor``, which Temporal's workflow -event loop does not support. - -The in-workflow ``tools`` node is a plain ``async`` function rather than -LangGraph's ``ToolNode`` prebuilt on purpose. The plugin wraps an in-workflow -node in ``wrap_workflow``, whose closure captures the wrapped object. When that -object is itself a LangChain ``Runnable`` (as ``ToolNode`` is), LangGraph's -``compile()`` subgraph detection (``find_subgraph_pregel`` → -``get_function_nonlocals``) recurses through that wrapper without cycle -detection and never terminates, tripping Temporal's deadlock detector. A plain -function isn't a ``Runnable``, so compile stays trivial. +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 @@ -40,10 +25,8 @@ from project.tools import TOOLS -# Look up tools by name for the in-workflow tools node. _TOOLS_BY_NAME = {tool.name: tool for tool in TOOLS} -# Name this graph is registered under in the LangGraphPlugin (acp.py / run_worker.py). GRAPH_NAME = "at130-langgraph" MODEL_NAME = "gpt-4o" SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. @@ -62,37 +45,27 @@ async def agent_node(state: AgentState) -> dict[str, Any]: 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")) - ) + 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. - - A plain ``async`` function (not LangGraph's ``ToolNode``) — see the module - docstring for why a ``Runnable`` tools node can't be compiled here. - """ + """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"]) - # Mirror ToolNode: surface an unknown/hallucinated tool name as an error - # ToolMessage so the graph keeps running instead of crashing the node. 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"]) - ) + 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 (async router).""" + """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 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 index 7040f560b..4b31bf396 100644 --- 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 @@ -5,8 +5,7 @@ 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. +activities and registers them on the worker automatically. """ import asyncio @@ -14,7 +13,7 @@ from temporalio.contrib.langgraph import LangGraphPlugin from project.graph import GRAPH_NAME, build_graph -from project.workflow import At130LanggraphWorkflow +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 @@ -32,9 +31,6 @@ async def main(): 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()})], @@ -42,9 +38,9 @@ async def main(): await worker.run( activities=get_all_activities(), - workflow=At130LanggraphWorkflow, + workflow=AtHarnessLanggraphWorkflow, ) if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + 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 index 20b7185ee..e7220016e 100644 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/project/tools.py +++ b/examples/tutorials/10_async/10_temporal/130_langgraph/project/tools.py @@ -1,20 +1,37 @@ -"""Tools for the LangGraph agent. +"""Tool definitions for the 130_langgraph temporal agent.""" -Tools are ``async`` so the in-workflow tool node can await them directly -(a sync tool would be offloaded via ``run_in_executor``, which Temporal's -workflow event loop does not allow). -""" +from langchain_core.tools import Tool -from __future__ import annotations -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. -@tool -async def get_weather(city: str) -> str: - """Get the current weather for a city.""" - # TODO: replace with a real weather API call. + Returns: + A string describing the weather conditions. + """ return f"The weather in {city} is sunny and 72°F" -TOOLS = [get_weather] +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 index a50670251..b9224ca00 100644 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/project/workflow.py +++ b/examples/tutorials/10_async/10_temporal/130_langgraph/project/workflow.py @@ -1,4 +1,4 @@ -"""Temporal workflow for at130-langgraph — Temporal as the LangGraph runtime. +"""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 @@ -37,7 +37,7 @@ @workflow.defn(name=environment_variables.WORKFLOW_NAME) -class At130LanggraphWorkflow(BaseWorkflow): +class AtHarnessLanggraphWorkflow(BaseWorkflow): """Runs the LangGraph agent each turn; its nodes run as Temporal activities.""" def __init__(self) -> None: @@ -56,10 +56,7 @@ async def on_task_event_send(self, params: SendEventParams) -> None: result = await compiled.ainvoke({"messages": self._messages}) self._messages = result["messages"] - # Surface the messages this turn produced (tool calls, results, final - # text) to the AgentEx UI. The SDK helper does the LangGraph→AgentEx - # message conversion. - await emit_langgraph_messages(self._messages[self._emitted:], params.task.id) + await emit_langgraph_messages(self._messages[self._emitted :], params.task.id) self._emitted = len(self._messages) @workflow.signal diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/pyproject.toml b/examples/tutorials/10_async/10_temporal/130_langgraph/pyproject.toml index e22905de4..6d2262761 100644 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/pyproject.toml +++ b/examples/tutorials/10_async/10_temporal/130_langgraph/pyproject.toml @@ -5,13 +5,11 @@ build-backend = "hatchling.build" [project] name = "at130-langgraph" version = "0.1.0" -description = "A Temporal-backed LangGraph agent whose nodes run as Temporal activities" +description = "A Temporal-backed LangGraph agent (harness variant) whose nodes run as Temporal activities" requires-python = ">=3.12" dependencies = [ "agentex-sdk", "scale-gp", - # Temporal with the LangGraph plugin (temporalio.contrib.langgraph), - # which runs LangGraph nodes as Temporal activities. Needs >=1.27.0. "temporalio[langgraph]>=1.27.0", "langchain-openai", "langchain-core", @@ -39,4 +37,4 @@ target-version = ['py312'] [tool.isort] profile = "black" -line_length = 88 \ No newline at end of file +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 index b798f568f..f2292389f 100644 --- 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 @@ -1,4 +1,4 @@ -"""Integration tests for the Temporal + LangGraph agent (live agent required). +"""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, @@ -6,9 +6,6 @@ (proving the LLM node ran as a Temporal activity and the tool node ran), - the final answer reflects the tool output. -For fast, network-free coverage of the graph + human-in-the-loop logic, see -``test_graph_temporal.py``. - To run: 1. Start the agent (worker + ACP server): ``agentex agents run --manifest manifest.yaml`` 2. Set AGENTEX_API_BASE_URL if not using the default @@ -60,29 +57,18 @@ class TestNonStreamingEvents: @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_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 - ): + 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" - ): + if message.content and message.content.type == "text" and message.content.author == "agent": task_creation_found = True break assert task_creation_found, "Task creation welcome message not found" - # Ask about weather — the agent (LangGraph node, as a Temporal activity) - # should call get_weather. seen_tool_request = False seen_tool_response = False final_message = None @@ -101,11 +87,7 @@ async def test_send_event_and_poll(self, client: AsyncAgentex, agent_id: str): 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" - ): + 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: @@ -115,11 +97,8 @@ async def test_send_event_and_poll(self, client: AsyncAgentex, agent_id: str): 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 - ) + final_text = getattr(final_message.content, "content", None) if final_message.content else None assert isinstance(final_text, str) and len(final_text) > 0 - # get_weather always returns "72°F" — the response should mention it. assert "72" in final_text, "Expected weather response to mention 72°F" diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/tests/test_graph_temporal.py b/examples/tutorials/10_async/10_temporal/130_langgraph/tests/test_graph_temporal.py deleted file mode 100644 index 485b896f6..000000000 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/tests/test_graph_temporal.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Tests for the Temporal + LangGraph agent's graph. - -Two layers: - -1. ``TestGraphLogic`` — hermetic, no network. Compiles the actual shipped - graph (``project/graph.py``) with a deterministic stub model and runs the - ReAct loop (agent → tools → agent) to completion. - -2. ``TestTemporalPlugin`` — end-to-end through the real Temporal LangGraph - plugin on a local Temporal server, proving the LLM node runs as an activity - and the tool node in the workflow. Needs a real model, so it is skipped - unless ``LITELLM_API_KEY`` (or ``OPENAI_API_KEY``) is set. - -Run from the agent's own (uv) environment: pytest tests/test_graph_temporal.py -v -""" - -from __future__ import annotations - -import os -import uuid - -import pytest - -pytest.importorskip("langgraph") -pytest.importorskip("temporalio.contrib.langgraph") - -import project.graph as graph_module -from temporalio import workflow -from project.graph import GRAPH_NAME, build_graph -from langchain_core.messages import AIMessage, ToolMessage -from temporalio.contrib.langgraph import graph as lg_graph - - -@workflow.defn -class _DriverWorkflow: - """Module-level driver workflow (Temporal forbids local workflow classes).""" - - @workflow.run - async def run(self, message: str) -> str: - compiled = lg_graph(GRAPH_NAME).compile() - result = await compiled.ainvoke({"messages": [{"role": "user", "content": message}]}) - return result["messages"][-1].content - - -class _StubModel: - """Deterministic stand-in for ``ChatOpenAI(...).bind_tools(...)``. - - First call → emit a tool call for ``get_weather``; once a ToolMessage is in - the history → emit a plain text answer. Drives the full ReAct loop offline. - """ - - def bind_tools(self, _tools): - return self - - async def ainvoke(self, messages): - if any(isinstance(m, ToolMessage) for m in messages): - return AIMessage(content="All done — the tool has run.") - return AIMessage( - content="", - tool_calls=[{"id": "call_1", "name": "get_weather", "args": {"city": "Denver"}}], - ) - - -class TestGraphLogic: - """Hermetic test of the ReAct loop, no network.""" - - @pytest.mark.asyncio - async def test_react_loop_runs_tool(self, monkeypatch): - monkeypatch.setattr(graph_module, "ChatOpenAI", lambda *_a, **_k: _StubModel()) - compiled = build_graph().compile() - result = await compiled.ainvoke({"messages": [{"role": "user", "content": "go"}]}) - - tool_outputs = [m.content for m in result["messages"] if isinstance(m, ToolMessage)] - assert any("sunny" in o for o in tool_outputs) - assert "done" in result["messages"][-1].content.lower() - - -@pytest.mark.skipif( - not (os.environ.get("LITELLM_API_KEY") or os.environ.get("OPENAI_API_KEY")), - reason="needs a real model (set LITELLM_API_KEY) for the live Temporal run", -) -class TestTemporalPlugin: - """End-to-end through the real Temporal LangGraph plugin on a local server.""" - - @pytest.mark.asyncio - async def test_nodes_run_as_activities_via_plugin(self): - from temporalio.worker import Worker, UnsandboxedWorkflowRunner - from temporalio.testing import WorkflowEnvironment - from temporalio.contrib.langgraph import LangGraphPlugin - - plugin = LangGraphPlugin(graphs={GRAPH_NAME: build_graph()}) - async with await WorkflowEnvironment.start_local(plugins=[plugin]) as env: - async with Worker( - env.client, - task_queue="tq", - workflows=[_DriverWorkflow], - workflow_runner=UnsandboxedWorkflowRunner(), - ): - out = await env.client.execute_workflow( - _DriverWorkflow.run, - "What's the weather in Denver? Use the get_weather tool.", - id=f"wf-{uuid.uuid4()}", - task_queue="tq", - ) - assert "denver" in out.lower() diff --git a/examples/tutorials/10_async/10_temporal/140_harness_openai/.dockerignore b/examples/tutorials/10_async/10_temporal/140_harness_openai/.dockerignore deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/10_async/10_temporal/140_harness_openai/.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_harness_openai/Dockerfile b/examples/tutorials/10_async/10_temporal/140_harness_openai/Dockerfile deleted file mode 100644 index c107e3269..000000000 --- a/examples/tutorials/10_async/10_temporal/140_harness_openai/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/140_harness_openai/pyproject.toml /app/140_harness_openai/pyproject.toml -COPY 10_async/10_temporal/140_harness_openai/README.md /app/140_harness_openai/README.md - -WORKDIR /app/140_harness_openai - -COPY 10_async/10_temporal/140_harness_openai/project /app/140_harness_openai/project -COPY 10_async/10_temporal/140_harness_openai/tests /app/140_harness_openai/tests -COPY test_utils /app/test_utils - -RUN uv pip install --system .[dev] - -ENV PYTHONPATH=/app - -ENV AGENT_NAME=at140-harness-openai - -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/140_harness_openai/environments.yaml b/examples/tutorials/10_async/10_temporal/140_harness_openai/environments.yaml deleted file mode 100644 index f90511911..000000000 --- a/examples/tutorials/10_async/10_temporal/140_harness_openai/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/140_harness_openai/project/__init__.py b/examples/tutorials/10_async/10_temporal/140_harness_openai/project/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/10_async/00_base/harness_pydantic_ai/.dockerignore b/examples/tutorials/10_async/10_temporal/150_codex/.dockerignore similarity index 100% rename from examples/tutorials/10_async/00_base/harness_pydantic_ai/.dockerignore rename to examples/tutorials/10_async/10_temporal/150_codex/.dockerignore diff --git a/examples/tutorials/10_async/10_temporal/harness_codex/Dockerfile b/examples/tutorials/10_async/10_temporal/150_codex/Dockerfile similarity index 66% rename from examples/tutorials/10_async/10_temporal/harness_codex/Dockerfile rename to examples/tutorials/10_async/10_temporal/150_codex/Dockerfile index e2f8807fd..9561548c4 100644 --- a/examples/tutorials/10_async/10_temporal/harness_codex/Dockerfile +++ b/examples/tutorials/10_async/10_temporal/150_codex/Dockerfile @@ -22,19 +22,19 @@ RUN uv pip install --system --upgrade pip setuptools wheel ENV UV_HTTP_TIMEOUT=1000 -COPY 10_async/10_temporal/harness_codex/pyproject.toml /app/harness_codex/pyproject.toml -COPY 10_async/10_temporal/harness_codex/README.md /app/harness_codex/README.md +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/harness_codex +WORKDIR /app/150_codex -COPY 10_async/10_temporal/harness_codex/project /app/harness_codex/project -COPY 10_async/10_temporal/harness_codex/tests /app/harness_codex/tests +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=at-harness-codex +ENV AGENT_NAME=at150-codex CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_async/10_temporal/harness_codex/README.md b/examples/tutorials/10_async/10_temporal/150_codex/README.md similarity index 95% rename from examples/tutorials/10_async/10_temporal/harness_codex/README.md rename to examples/tutorials/10_async/10_temporal/150_codex/README.md index 4f9b76955..498b81374 100644 --- a/examples/tutorials/10_async/10_temporal/harness_codex/README.md +++ b/examples/tutorials/10_async/10_temporal/150_codex/README.md @@ -1,4 +1,4 @@ -# harness_codex (Temporal) +# 150_codex (Temporal) Tutorial agent demonstrating the `convert_codex_to_agentex_events` tap, `CodexTurn`, and `UnifiedEmitter` for a **Temporal-durable** async ACP agent. @@ -36,7 +36,7 @@ Live runs require: ```bash cd /path/to/scale-agentex-python -uv run --all-packages --all-extras pytest examples/tutorials/10_async/10_temporal/harness_codex/tests/test_agent.py -q +uv run --all-packages --all-extras pytest examples/tutorials/10_async/10_temporal/150_codex/tests/test_agent.py -q ``` ## Running live integration tests diff --git a/examples/tutorials/10_async/10_temporal/harness_codex/conftest.py b/examples/tutorials/10_async/10_temporal/150_codex/conftest.py similarity index 72% rename from examples/tutorials/10_async/10_temporal/harness_codex/conftest.py rename to examples/tutorials/10_async/10_temporal/150_codex/conftest.py index 4ae6ce61a..6370f278d 100644 --- a/examples/tutorials/10_async/10_temporal/harness_codex/conftest.py +++ b/examples/tutorials/10_async/10_temporal/150_codex/conftest.py @@ -11,7 +11,7 @@ # 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", "at-harness-codex") +os.environ.setdefault("AGENT_NAME", "at150-codex") os.environ.setdefault("ACP_URL", "http://localhost:8000") -os.environ.setdefault("WORKFLOW_NAME", "at-harness-codex") -os.environ.setdefault("WORKFLOW_TASK_QUEUE", "at_harness_codex_queue") +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/harness_codex/manifest.yaml b/examples/tutorials/10_async/10_temporal/150_codex/manifest.yaml similarity index 80% rename from examples/tutorials/10_async/10_temporal/harness_codex/manifest.yaml rename to examples/tutorials/10_async/10_temporal/150_codex/manifest.yaml index 3bc21dccc..d64bdfad0 100644 --- a/examples/tutorials/10_async/10_temporal/harness_codex/manifest.yaml +++ b/examples/tutorials/10_async/10_temporal/150_codex/manifest.yaml @@ -2,10 +2,10 @@ build: context: root: ../../../ include_paths: - - 10_async/10_temporal/harness_codex + - 10_async/10_temporal/150_codex - test_utils - dockerfile: 10_async/10_temporal/harness_codex/Dockerfile - dockerignore: 10_async/10_temporal/harness_codex/.dockerignore + dockerfile: 10_async/10_temporal/150_codex/Dockerfile + dockerignore: 10_async/10_temporal/150_codex/.dockerignore local_development: agent: @@ -17,14 +17,14 @@ local_development: agent: acp_type: async - name: at-harness-codex + name: at150-codex description: Temporal tutorial agent driving the unified harness surface via local codex CLI subprocess temporal: enabled: true workflows: - - name: at-harness-codex - queue_name: at_harness_codex_queue + - name: at150-codex + queue_name: at150_codex_queue credentials: - env_var_name: OPENAI_API_KEY @@ -50,7 +50,7 @@ deployment: global: agent: - name: "at-harness-codex" + name: "at150-codex" description: "Temporal tutorial agent driving the unified harness surface via local codex CLI subprocess" replicaCount: 1 resources: diff --git a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/__init__.py b/examples/tutorials/10_async/10_temporal/150_codex/project/__init__.py similarity index 100% rename from examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/__init__.py rename to examples/tutorials/10_async/10_temporal/150_codex/project/__init__.py diff --git a/examples/tutorials/10_async/10_temporal/harness_codex/project/acp.py b/examples/tutorials/10_async/10_temporal/150_codex/project/acp.py similarity index 100% rename from examples/tutorials/10_async/10_temporal/harness_codex/project/acp.py rename to examples/tutorials/10_async/10_temporal/150_codex/project/acp.py diff --git a/examples/tutorials/10_async/10_temporal/harness_codex/project/activities.py b/examples/tutorials/10_async/10_temporal/150_codex/project/activities.py similarity index 100% rename from examples/tutorials/10_async/10_temporal/harness_codex/project/activities.py rename to examples/tutorials/10_async/10_temporal/150_codex/project/activities.py diff --git a/examples/tutorials/10_async/10_temporal/harness_codex/project/run_worker.py b/examples/tutorials/10_async/10_temporal/150_codex/project/run_worker.py similarity index 100% rename from examples/tutorials/10_async/10_temporal/harness_codex/project/run_worker.py rename to examples/tutorials/10_async/10_temporal/150_codex/project/run_worker.py diff --git a/examples/tutorials/10_async/10_temporal/harness_codex/project/workflow.py b/examples/tutorials/10_async/10_temporal/150_codex/project/workflow.py similarity index 100% rename from examples/tutorials/10_async/10_temporal/harness_codex/project/workflow.py rename to examples/tutorials/10_async/10_temporal/150_codex/project/workflow.py diff --git a/examples/tutorials/10_async/10_temporal/harness_codex/pyproject.toml b/examples/tutorials/10_async/10_temporal/150_codex/pyproject.toml similarity index 96% rename from examples/tutorials/10_async/10_temporal/harness_codex/pyproject.toml rename to examples/tutorials/10_async/10_temporal/150_codex/pyproject.toml index c4d67d285..7e1d6250f 100644 --- a/examples/tutorials/10_async/10_temporal/harness_codex/pyproject.toml +++ b/examples/tutorials/10_async/10_temporal/150_codex/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "at-harness-codex" +name = "at150-codex" version = "0.1.0" description = "Temporal tutorial agent driving the unified harness surface via local codex CLI subprocess" readme = "README.md" diff --git a/examples/tutorials/10_async/10_temporal/harness_codex/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/150_codex/tests/test_agent.py similarity index 99% rename from examples/tutorials/10_async/10_temporal/harness_codex/tests/test_agent.py rename to examples/tutorials/10_async/10_temporal/150_codex/tests/test_agent.py index 2066b35b1..fa6c66083 100644 --- a/examples/tutorials/10_async/10_temporal/harness_codex/tests/test_agent.py +++ b/examples/tutorials/10_async/10_temporal/150_codex/tests/test_agent.py @@ -213,7 +213,7 @@ async def _auto_send(_self, turn, *_a, **_kw): 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", "at-harness-codex") +AGENT_NAME = os.environ.get("AGENT_NAME", "at150-codex") @pytest.mark.skipif( diff --git a/examples/tutorials/10_async/10_temporal/harness_codex/project/__init__.py b/examples/tutorials/10_async/10_temporal/harness_codex/project/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/10_async/10_temporal/harness_langgraph/README.md b/examples/tutorials/10_async/10_temporal/harness_langgraph/README.md deleted file mode 100644 index 4df6969f1..000000000 --- a/examples/tutorials/10_async/10_temporal/harness_langgraph/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# Tutorial: Temporal Harness LangGraph Agent - -This tutorial demonstrates how to build a **Temporal-backed** LangGraph agent on -AgentEx, following the ``130_langgraph`` pattern. The agent's LLM node runs as a -durable Temporal activity; the tools node runs inline in the workflow. - -This agent is named ``at-harness-langgraph`` to distinguish it from -``at130-langgraph`` (the bespoke reference). The graph and workflow structure are -identical; only the agent name changes. - -## 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: at-harness-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/harness_langgraph/manifest.yaml b/examples/tutorials/10_async/10_temporal/harness_langgraph/manifest.yaml deleted file mode 100644 index 596d38eb4..000000000 --- a/examples/tutorials/10_async/10_temporal/harness_langgraph/manifest.yaml +++ /dev/null @@ -1,51 +0,0 @@ -build: - context: - root: ../../../ - include_paths: - - 10_async/10_temporal/harness_langgraph - - test_utils - dockerfile: 10_async/10_temporal/harness_langgraph/Dockerfile - dockerignore: 10_async/10_temporal/harness_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: at-harness-langgraph - description: "A Temporal-backed LangGraph agent (harness variant) whose nodes run as Temporal activities" - - temporal: - enabled: true - workflows: - - name: at-harness-langgraph - queue_name: at_harness_langgraph_queue - - credentials: - - env_var_name: REDIS_URL - secret_name: redis-url-secret - secret_key: url - - env: {} - -deployment: - image: - repository: "" - tag: "latest" - - imagePullSecrets: [] - - global: - replicaCount: 1 - resources: - requests: - cpu: "500m" - memory: "1Gi" - limits: - cpu: "1000m" - memory: "2Gi" diff --git a/examples/tutorials/10_async/10_temporal/harness_langgraph/project/__init__.py b/examples/tutorials/10_async/10_temporal/harness_langgraph/project/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/10_async/10_temporal/harness_langgraph/project/acp.py b/examples/tutorials/10_async/10_temporal/harness_langgraph/project/acp.py deleted file mode 100644 index 7af9c5e68..000000000 --- a/examples/tutorials/10_async/10_temporal/harness_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/harness_langgraph/project/graph.py b/examples/tutorials/10_async/10_temporal/harness_langgraph/project/graph.py deleted file mode 100644 index ce9c2b520..000000000 --- a/examples/tutorials/10_async/10_temporal/harness_langgraph/project/graph.py +++ /dev/null @@ -1,85 +0,0 @@ -"""LangGraph graph for at-harness-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 = "at-harness-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/harness_langgraph/project/run_worker.py b/examples/tutorials/10_async/10_temporal/harness_langgraph/project/run_worker.py deleted file mode 100644 index ca64464fc..000000000 --- a/examples/tutorials/10_async/10_temporal/harness_langgraph/project/run_worker.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Temporal worker for at-harness-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/harness_langgraph/project/tools.py b/examples/tutorials/10_async/10_temporal/harness_langgraph/project/tools.py deleted file mode 100644 index 10943c9d2..000000000 --- a/examples/tutorials/10_async/10_temporal/harness_langgraph/project/tools.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Tool definitions for the harness_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/harness_langgraph/project/workflow.py b/examples/tutorials/10_async/10_temporal/harness_langgraph/project/workflow.py deleted file mode 100644 index 4125dca39..000000000 --- a/examples/tutorials/10_async/10_temporal/harness_langgraph/project/workflow.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Temporal workflow for at-harness-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/harness_langgraph/pyproject.toml b/examples/tutorials/10_async/10_temporal/harness_langgraph/pyproject.toml deleted file mode 100644 index 897f54dd6..000000000 --- a/examples/tutorials/10_async/10_temporal/harness_langgraph/pyproject.toml +++ /dev/null @@ -1,40 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "at-harness-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/harness_langgraph/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/harness_langgraph/tests/test_agent.py deleted file mode 100644 index 05d9ffa01..000000000 --- a/examples/tutorials/10_async/10_temporal/harness_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", "at-harness-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/harness_pydantic_ai/.dockerignore b/examples/tutorials/10_async/10_temporal/harness_pydantic_ai/.dockerignore deleted file mode 100644 index c49489471..000000000 --- a/examples/tutorials/10_async/10_temporal/harness_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/harness_pydantic_ai/Dockerfile b/examples/tutorials/10_async/10_temporal/harness_pydantic_ai/Dockerfile deleted file mode 100644 index 98c74c6e8..000000000 --- a/examples/tutorials/10_async/10_temporal/harness_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/harness_pydantic_ai/pyproject.toml /app/harness_pydantic_ai/pyproject.toml -COPY 10_async/10_temporal/harness_pydantic_ai/README.md /app/harness_pydantic_ai/README.md - -WORKDIR /app/harness_pydantic_ai - -COPY 10_async/10_temporal/harness_pydantic_ai/project /app/harness_pydantic_ai/project -COPY 10_async/10_temporal/harness_pydantic_ai/tests /app/harness_pydantic_ai/tests -COPY test_utils /app/test_utils - -RUN uv pip install --system .[dev] - -ENV PYTHONPATH=/app - -ENV AGENT_NAME=at-harness-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/harness_pydantic_ai/README.md b/examples/tutorials/10_async/10_temporal/harness_pydantic_ai/README.md deleted file mode 100644 index 3e5fef4c6..000000000 --- a/examples/tutorials/10_async/10_temporal/harness_pydantic_ai/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# Temporal Pydantic AI Harness Test 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 - -The `10_async/10_temporal/110_pydantic_ai` tutorial streams via the -`stream_pydantic_ai_events` helper (which uses the unified surface internally). -This harness test 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 at -`tests/lib/core/harness/test_harness_pydantic_ai_temporal.py`. diff --git a/examples/tutorials/10_async/10_temporal/harness_pydantic_ai/manifest.yaml b/examples/tutorials/10_async/10_temporal/harness_pydantic_ai/manifest.yaml deleted file mode 100644 index 9efbff918..000000000 --- a/examples/tutorials/10_async/10_temporal/harness_pydantic_ai/manifest.yaml +++ /dev/null @@ -1,62 +0,0 @@ -build: - context: - root: ../../../ - include_paths: - - 10_async/10_temporal/harness_pydantic_ai - - test_utils - dockerfile: 10_async/10_temporal/harness_pydantic_ai/Dockerfile - dockerignore: 10_async/10_temporal/harness_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: at-harness-pydantic-ai - description: A Temporal-backed Pydantic AI harness test agent using the unified emitter surface - - temporal: - enabled: true - workflows: - - name: at-harness-pydantic-ai - queue_name: at_harness_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: "at-harness-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/harness_pydantic_ai/project/__init__.py b/examples/tutorials/10_async/10_temporal/harness_pydantic_ai/project/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tutorials/10_async/10_temporal/harness_pydantic_ai/project/acp.py b/examples/tutorials/10_async/10_temporal/harness_pydantic_ai/project/acp.py deleted file mode 100644 index c142dcf70..000000000 --- a/examples/tutorials/10_async/10_temporal/harness_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/harness_pydantic_ai/project/agent.py b/examples/tutorials/10_async/10_temporal/harness_pydantic_ai/project/agent.py deleted file mode 100644 index 5e8697264..000000000 --- a/examples/tutorials/10_async/10_temporal/harness_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="harness_pydantic_ai_agent", - event_stream_handler=event_handler, -) diff --git a/examples/tutorials/10_async/10_temporal/harness_pydantic_ai/project/run_worker.py b/examples/tutorials/10_async/10_temporal/harness_pydantic_ai/project/run_worker.py deleted file mode 100644 index 4b4d43d19..000000000 --- a/examples/tutorials/10_async/10_temporal/harness_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/harness_pydantic_ai/project/tools.py b/examples/tutorials/10_async/10_temporal/harness_pydantic_ai/project/tools.py deleted file mode 100644 index bbd6c5200..000000000 --- a/examples/tutorials/10_async/10_temporal/harness_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/harness_pydantic_ai/project/workflow.py b/examples/tutorials/10_async/10_temporal/harness_pydantic_ai/project/workflow.py deleted file mode 100644 index 9a01be7de..000000000 --- a/examples/tutorials/10_async/10_temporal/harness_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/harness_pydantic_ai/pyproject.toml b/examples/tutorials/10_async/10_temporal/harness_pydantic_ai/pyproject.toml deleted file mode 100644 index 4d9039640..000000000 --- a/examples/tutorials/10_async/10_temporal/harness_pydantic_ai/pyproject.toml +++ /dev/null @@ -1,38 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "at-harness-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/harness_pydantic_ai/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/harness_pydantic_ai/tests/test_agent.py deleted file mode 100644 index a5b90ca34..000000000 --- a/examples/tutorials/10_async/10_temporal/harness_pydantic_ai/tests/test_agent.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Live tests for the Temporal harness 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. They -mirror the ``at110`` temporal tutorial tests but target this harness agent. - -Offline coverage of the same wiring (TestModel + fake streaming/tracing) lives -in ``tests/lib/core/harness/test_harness_pydantic_ai_temporal.py`` in the SDK repo. - -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", "at-harness-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"]) From 46e5fbf8532c17c0e7a4c1574853739fdea166f1 Mon Sep 17 00:00:00 2001 From: Declan Brady Date: Tue, 23 Jun 2026 16:24:05 -0400 Subject: [PATCH 2/4] fix(tutorials): wire model credential + deployment agent metadata for at130-langgraph Greptile review on #428: the 130_langgraph temporal tutorial's graph.py builds ChatOpenAI(model=MODEL_NAME) but the manifest only mapped REDIS_URL, so a deployed worker would fail on its first model call. Add the OPENAI_API_KEY credential and the deployment.global.agent name/description block to match the sibling migrated tutorials (e.g. at110-pydantic-ai). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../10_async/10_temporal/130_langgraph/manifest.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/examples/tutorials/10_async/10_temporal/130_langgraph/manifest.yaml b/examples/tutorials/10_async/10_temporal/130_langgraph/manifest.yaml index 936ebfa68..534c8dd58 100644 --- a/examples/tutorials/10_async/10_temporal/130_langgraph/manifest.yaml +++ b/examples/tutorials/10_async/10_temporal/130_langgraph/manifest.yaml @@ -30,6 +30,11 @@ agent: - 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: {} @@ -41,6 +46,9 @@ deployment: imagePullSecrets: [] global: + agent: + name: "at130-langgraph" + description: "A Temporal-backed LangGraph agent (harness variant) whose nodes run as Temporal activities" replicaCount: 1 resources: requests: From 75ab5c743bbd231a7dfee965a684b28c80286358 Mon Sep 17 00:00:00 2001 From: Declan Brady Date: Tue, 23 Jun 2026 16:39:50 -0400 Subject: [PATCH 3/4] fix(tutorials): install codex CLI in codex tutorial images Greptile review on #428: the sync/async/temporal codex tutorials spawn `codex exec --json` but their Dockerfiles installed only OS+Python deps, so a live request/activity would fail with codex not on PATH. Add nodejs/npm and `npm install -g @openai/codex` to all three images. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/tutorials/00_sync/070_codex/Dockerfile | 6 ++++++ examples/tutorials/10_async/00_base/140_codex/Dockerfile | 6 ++++++ .../tutorials/10_async/10_temporal/150_codex/Dockerfile | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/examples/tutorials/00_sync/070_codex/Dockerfile b/examples/tutorials/00_sync/070_codex/Dockerfile index 75abf677d..fb500b221 100644 --- a/examples/tutorials/00_sync/070_codex/Dockerfile +++ b/examples/tutorials/00_sync/070_codex/Dockerfile @@ -15,9 +15,15 @@ RUN apt-get update && apt-get install -y \ 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 diff --git a/examples/tutorials/10_async/00_base/140_codex/Dockerfile b/examples/tutorials/10_async/00_base/140_codex/Dockerfile index ca5b99ffe..0dd839d8c 100644 --- a/examples/tutorials/10_async/00_base/140_codex/Dockerfile +++ b/examples/tutorials/10_async/00_base/140_codex/Dockerfile @@ -15,9 +15,15 @@ RUN apt-get update && apt-get install -y \ 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 diff --git a/examples/tutorials/10_async/10_temporal/150_codex/Dockerfile b/examples/tutorials/10_async/10_temporal/150_codex/Dockerfile index 9561548c4..e861c7f33 100644 --- a/examples/tutorials/10_async/10_temporal/150_codex/Dockerfile +++ b/examples/tutorials/10_async/10_temporal/150_codex/Dockerfile @@ -15,9 +15,15 @@ RUN apt-get update && apt-get install -y \ 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 From ea9959fa244b05dd6c029b10534122b90d9caf22 Mon Sep 17 00:00:00 2001 From: Declan Brady Date: Tue, 23 Jun 2026 16:39:50 -0400 Subject: [PATCH 4/4] fix(tutorials): pass deterministic created_at in 120_openai_agents workflow Greptile review on #428: thread workflow.now() through RunHarnessAgentParams to auto_send_turn so a retried activity re-emits the turn's messages with stable timestamps instead of new server-side ones (which could reorder/duplicate them). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../10_temporal/120_openai_agents/project/activities.py | 7 ++++++- .../10_temporal/120_openai_agents/project/workflow.py | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) 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 index 2a8a773c4..72c92d617 100644 --- 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 @@ -13,6 +13,7 @@ from __future__ import annotations from typing import Any +from datetime import datetime from agents import Runner from pydantic import BaseModel @@ -38,6 +39,10 @@ class RunHarnessAgentParams(BaseModel): 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): @@ -70,6 +75,6 @@ async def run_openai_agent(self, params: RunHarnessAgentParams) -> RunHarnessAge trace_id=params.trace_id, parent_span_id=params.parent_span_id, ) - turn_result = await emitter.auto_send_turn(turn) + 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/workflow.py b/examples/tutorials/10_async/10_temporal/120_openai_agents/project/workflow.py index 566bd93b6..5cb8fb38b 100644 --- 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 @@ -84,6 +84,9 @@ async def on_task_event_send(self, params: SendEventParams) -> None: 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),