diff --git a/.github/scripts/sync_agents.py b/.github/scripts/sync_agents.py new file mode 100644 index 00000000..e69de29b diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d22577f4..0cc9a4f7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,7 +2,5 @@ name: Test AgentEx Tutorials on: workflow_dispatch: - - workflow_call: - + workflow_call: diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 7e8ac3bd..6ebf1386 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -21,8 +21,8 @@ jobs: curl -sSf https://rye.astral.sh/get | bash echo "$HOME/.rye/shims" >> $GITHUB_PATH env: - RYE_VERSION: '0.44.0' - RYE_INSTALL_OPTION: '--yes' + RYE_VERSION: "0.44.0" + RYE_INSTALL_OPTION: "--yes" - name: Publish to PyPI run: | diff --git a/.gitignore b/.gitignore index 898a822a..0abdace2 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,6 @@ dist codegen.log Brewfile.lock.json -.DS_Store \ No newline at end of file +.DS_Store + +examples/**/uv.lock \ No newline at end of file diff --git a/examples/tutorials/00_sync/000_hello_acp/Dockerfile b/examples/tutorials/00_sync/000_hello_acp/Dockerfile index 2c42e6ff..fb42b8ec 100644 --- a/examples/tutorials/00_sync/000_hello_acp/Dockerfile +++ b/examples/tutorials/00_sync/000_hello_acp/Dockerfile @@ -22,16 +22,17 @@ RUN uv pip install --system --upgrade pip setuptools wheel ENV UV_HTTP_TIMEOUT=1000 -# Copy just the requirements file to optimize caching -COPY 000_hello_acp/requirements.txt /app/requirements.txt +# Copy pyproject.toml and README.md to install dependencies +COPY 000_hello_acp/pyproject.toml /app/000_hello_acp/pyproject.toml +COPY 000_hello_acp/README.md /app/000_hello_acp/README.md -WORKDIR /app/ - -# Install the required Python packages -RUN uv pip install --system -r requirements.txt +WORKDIR /app/000_hello_acp # Copy the project code -COPY 000_hello_acp/project /app/project +COPY 000_hello_acp/project /app/000_hello_acp/project + +# Install the required Python packages from pyproject.toml +RUN uv pip install --system . # Set environment variables ENV PYTHONPATH=/app diff --git a/examples/tutorials/00_sync/000_hello_acp/manifest.yaml b/examples/tutorials/00_sync/000_hello_acp/manifest.yaml index 9afecd43..efb2fd78 100644 --- a/examples/tutorials/00_sync/000_hello_acp/manifest.yaml +++ b/examples/tutorials/00_sync/000_hello_acp/manifest.yaml @@ -15,7 +15,7 @@ build: context: # Root directory for the build context - root: ../ # Keep this as the default root + root: ../ # Keep this as the default root # Paths to include in the Docker build context # Must include: @@ -34,14 +34,13 @@ build: # Helps keep build context small and builds fast dockerignore: 000_hello_acp/.dockerignore - # Local Development Configuration # ----------------------------- # Only used when running the agent locally local_development: agent: - port: 8000 # Port where your local ACP server is running - host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) # File paths for local development (relative to this manifest.yaml) paths: @@ -53,7 +52,6 @@ local_development: # /absolute/path/acp.py (absolute path) acp: project/acp.py - # Agent Configuration # ----------------- agent: @@ -83,7 +81,7 @@ agent: # secret_name: openai-api-key # secret_key: api-key - # Optional: Set Environment variables for running your agent locally as well + # Optional: Set Environment variables for running your agent locally as well # as for deployment later on # env: # - name: OPENAI_BASE_URL @@ -91,7 +89,6 @@ agent: # - name: ACCOUNT_ID # value: "your_account_id_here" - # Deployment Configuration # ----------------------- # Configuration for deploying your agent to Kubernetes clusters @@ -99,7 +96,7 @@ deployment: # Container image configuration image: repository: "" # Update with your container registry - tag: "latest" # Default tag, should be versioned in production + tag: "latest" # Default tag, should be versioned in production # Global deployment settings that apply to all clusters # These can be overridden in cluster-specific files (deploy/*.yaml) @@ -107,10 +104,10 @@ deployment: agent: name: "s000-hello-acp" description: "An AgentEx agent that just says hello and acknowledges the user's message" - + # Default replica count replicaCount: 1 - + # Default resource requirements resources: requests: @@ -118,4 +115,5 @@ deployment: memory: "1Gi" limits: cpu: "1000m" - memory: "2Gi" \ No newline at end of file + memory: "2Gi" + diff --git a/examples/tutorials/00_sync/000_hello_acp/pyproject.toml b/examples/tutorials/00_sync/000_hello_acp/pyproject.toml index d190307e..0030004d 100644 --- a/examples/tutorials/00_sync/000_hello_acp/pyproject.toml +++ b/examples/tutorials/00_sync/000_hello_acp/pyproject.toml @@ -11,6 +11,8 @@ requires-python = ">=3.12" dependencies = [ "agentex-sdk", "scale-gp", + "pytest", + "pytest-xdist" ] [project.optional-dependencies] @@ -30,4 +32,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/00_sync/000_hello_acp/tests/test_agent.py b/examples/tutorials/00_sync/000_hello_acp/tests/test_agent.py new file mode 100644 index 00000000..3d7fb2f3 --- /dev/null +++ b/examples/tutorials/00_sync/000_hello_acp/tests/test_agent.py @@ -0,0 +1,128 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming message sending +- Streaming message sending +- Task creation via RPC + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: hello-acp) +""" + +import os +from agentex.types import TextContentParam, TextDelta, TextContent +from agentex.types.agent_rpc_params import ParamsSendMessageRequest +from agentex.types.task_message_update import StreamTaskMessageDelta, StreamTaskMessageFull +import pytest +from agentex import Agentex + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "s000-hello-acp") + + +@pytest.fixture +def client(): + """Create an AgentEx client instance for testing.""" + client = Agentex(base_url=AGENTEX_API_BASE_URL) + yield client + # Clean up: close the client connection + client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +class TestNonStreamingMessages: + """Test non-streaming message sending.""" + + def test_send_simple_message(self, client: Agentex, agent_name: str): + """Test sending a simple message and receiving a response.""" + + message_content = "Hello, Agent! How are you?" + response = client.agents.send_message( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content=message_content, + type="text", + ) + ), + ) + result = response.result + assert result is not None + assert len(result) == 1 + message = result[0] + assert isinstance(message.content, TextContent) + assert ( + message.content.content + == f"Hello! I've received your message. Here's a generic response, but in future tutorials we'll see how you can get me to intelligently respond to your message. This is what I heard you say: {message_content}" + ) + + +class TestStreamingMessages: + """Test streaming message sending.""" + + def test_stream_simple_message(self, client: Agentex, agent_name: str): + """Test streaming a simple message and aggregating deltas.""" + + message_content = "Hello, Agent! Can you stream your response?" + aggregated_content = "" + full_content = "" + received_chunks = False + + for chunk in client.agents.send_message_stream( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content=message_content, + type="text", + ) + ), + ): + received_chunks = True + task_message_update = chunk.result + # Collect text deltas as they arrive or check full messages + if isinstance(task_message_update, StreamTaskMessageDelta) and task_message_update.delta is not None: + delta = task_message_update.delta + if isinstance(delta, TextDelta) and delta.text_delta is not None: + aggregated_content += delta.text_delta + + elif isinstance(task_message_update, StreamTaskMessageFull): + content = task_message_update.content + if isinstance(content, TextContent): + full_content = content.content + + if not full_content and not aggregated_content: + raise AssertionError("No content was received in the streaming response.") + if not received_chunks: + raise AssertionError("No streaming chunks were received, when at least 1 was expected.") + + if full_content: + assert ( + full_content + == f"Hello! I've received your message. Here's a generic response, but in future tutorials we'll see how you can get me to intelligently respond to your message. This is what I heard you say: {message_content}" + ) + + if aggregated_content: + assert ( + aggregated_content + == f"Hello! I've received your message. Here's a generic response, but in future tutorials we'll see how you can get me to intelligently respond to your message. This is what I heard you say: {message_content}" + ) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/00_sync/010_multiturn/Dockerfile b/examples/tutorials/00_sync/010_multiturn/Dockerfile index 3609992b..b6a56303 100644 --- a/examples/tutorials/00_sync/010_multiturn/Dockerfile +++ b/examples/tutorials/00_sync/010_multiturn/Dockerfile @@ -22,19 +22,21 @@ RUN uv pip install --system --upgrade pip setuptools wheel ENV UV_HTTP_TIMEOUT=1000 -# Copy just the requirements file to optimize caching -COPY 010_multiturn/requirements.txt /app/requirements.txt +# Copy pyproject.toml and README.md to install dependencies +COPY 010_multiturn/pyproject.toml /app/010_multiturn/pyproject.toml +COPY 010_multiturn/README.md /app/010_multiturn/README.md -WORKDIR /app/ - -# Install the required Python packages -RUN uv pip install --system -r requirements.txt +WORKDIR /app/010_multiturn # Copy the project code -COPY 010_multiturn/project /app/project +COPY 010_multiturn/project /app/010_multiturn/project + +# Install the required Python packages from pyproject.toml +RUN uv pip install --system . +WORKDIR /app/010_multiturn # Set environment variables ENV PYTHONPATH=/app # Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/00_sync/010_multiturn/project/acp.py b/examples/tutorials/00_sync/010_multiturn/project/acp.py index d8a38040..5325a724 100644 --- a/examples/tutorials/00_sync/010_multiturn/project/acp.py +++ b/examples/tutorials/00_sync/010_multiturn/project/acp.py @@ -3,12 +3,12 @@ from agentex.lib import adk from agentex.lib.types.acp import SendMessageParams -from agentex.types.task_message import TaskMessageContent from agentex.lib.utils.model_utils import BaseModel from agentex.lib.types.llm_messages import LLMConfig, UserMessage, SystemMessage, AssistantMessage from agentex.lib.sdk.fastacp.fastacp import FastACP from agentex.types.task_message_update import TaskMessageUpdate -from agentex.types.task_message_content import TextContent +from agentex.types.task_message_content import TaskMessageContent +from agentex.types import TextContent # Create an ACP server acp = FastACP.create( @@ -24,7 +24,7 @@ class StateModel(BaseModel): # Note: The return of this handler is required to be persisted by the Agentex Server @acp.on_message_send async def handle_message_send( - params: SendMessageParams + params: SendMessageParams, ) -> Union[TaskMessageContent, AsyncGenerator[TaskMessageUpdate, None]]: """ In this tutorial, we'll see how to handle a basic multi-turn conversation without streaming. @@ -33,12 +33,12 @@ async def handle_message_send( # 0. Validate the message. ######################################################### - if not hasattr(params.content, 'type') or params.content.type != "text": + if not hasattr(params.content, "type") or params.content.type != "text": raise ValueError(f"Expected text message, got {getattr(params.content, 'type', 'unknown')}") - if not hasattr(params.content, 'author') or params.content.author != "user": + if not hasattr(params.content, "author") or params.content.author != "user": raise ValueError(f"Expected user message, got {getattr(params.content, 'author', 'unknown')}") - + if not os.environ.get("OPENAI_API_KEY"): return TextContent( author="agent", @@ -74,12 +74,14 @@ async def handle_message_send( llm_messages = [ SystemMessage(content=state.system_prompt), *[ - UserMessage(content=getattr(message.content, 'content', '')) if getattr(message.content, 'author', None) == "user" else AssistantMessage(content=getattr(message.content, 'content', '')) + UserMessage(content=getattr(message.content, "content", "")) + if getattr(message.content, "author", None) == "user" + else AssistantMessage(content=getattr(message.content, "content", "")) for message in task_messages - if getattr(message.content, 'type', None) == "text" - ] + if getattr(message.content, "type", None) == "text" + ], ] - + # TaskMessages are messages that are sent between an Agent and a Client. They are fundamentally decoupled from messages sent to the LLM. This is because you may want to send additional metadata to allow the client to render the message on the UI differently. # LLMMessages are OpenAI-compatible messages that are sent to the LLM, and are used to track the state of a conversation with a model. @@ -90,7 +92,7 @@ async def handle_message_send( # - Taking a markdown document output by an LLM, postprocessing it into a JSON object to clearly denote title, content, and footers. This can be sent as a DataContent TaskMessage to the client and converted back to markdown here to send back to the LLM. # - If using multiple LLMs (like in an actor-critic framework), you may want to send DataContent that denotes which LLM generated which part of the output and write conversion logic to split the TaskMessagehistory into multiple LLM conversations. # - If using multiple LLMs, but one LLM's output should not be sent to the user (i.e. a critic model), you can leverage the State as an internal storage mechanism to store the critic model's conversation history. This i s a powerful and flexible way to handle complex scenarios. - + ######################################################### # 4. Call an LLM to respond to the user's message. ######################################################### @@ -113,7 +115,4 @@ async def handle_message_send( else: content_str = "" - return TextContent( - author="agent", - content=content_str - ) + return TextContent(author="agent", content=content_str) diff --git a/examples/tutorials/00_sync/010_multiturn/tests/test_agent.py b/examples/tutorials/00_sync/010_multiturn/tests/test_agent.py new file mode 100644 index 00000000..3bcc8beb --- /dev/null +++ b/examples/tutorials/00_sync/010_multiturn/tests/test_agent.py @@ -0,0 +1,158 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming message sending +- Streaming message sending +- Task creation via RPC + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: s010-multiturn) +""" + +import enum +import os + +from agentex.lib.sdk.fastacp.base.base_acp_server import uuid +from agentex.lib.types.acp import SendMessageParams + +from test_utils.sync import collect_streaming_response, validate_text_in_string + +from agentex.types import TaskMessageContentParam, TextContent, TextContentParam, task_list_params +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest, ParamsSendMessageRequest +import pytest +from agentex import Agentex + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "s010-multiturn") + + +@pytest.fixture +def client(): + """Create an AgentEx client instance for testing.""" + return Agentex(base_url=AGENTEX_API_BASE_URL) + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest.fixture +def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingMessages: + """Test non-streaming message sending.""" + + def test_send_message(self, client: Agentex, agent_name: str, agent_id: str): + task_response = client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + + assert task is not None + + messages = [ + "Hello, can you tell me a litle bit about tennis? I want to you make sure you use the word 'tennis' in each response.", + "Pick one of the things you just mentioned, and dive deeper into it.", + "Can you now output a summary of this conversation", + ] + + for i, msg in enumerate(messages): + response = client.agents.send_message( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content=msg, + type="text", + ), + task_id=task.id, + ), + ) + assert response is not None and response.result is not None + result = response.result + + for message in result: + content = message.content + assert content is not None + assert isinstance(content, TextContent) and isinstance(content.content, str) + validate_text_in_string("tennis", content.content) + + states = client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + + state = states[0] + assert state.state is not None + assert state.state.get("system_prompt", None) == "You are a helpful assistant that can answer questions." + + message_history = client.messages.list( + task_id=task.id, + ) + assert len(message_history) == (i + 1) * 2 # user + agent messages + + +class TestStreamingMessages: + """Test streaming message sending.""" + + def test_stream_message(self, client: Agentex, agent_name: str, agent_id: str): + """Test streaming messages in a multi-turn conversation.""" + + # create a task for this specific conversation + task_response = client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + + assert task is not None + messages = [ + "Hello, can you tell me a little bit about tennis? I want you to make sure you use the word 'tennis' in each response.", + "Pick one of the things you just mentioned, and dive deeper into it.", + "Can you now output a summary of this conversation", + ] + + for i, msg in enumerate(messages): + stream = client.agents.send_message_stream( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content=msg, + type="text", + ), + task_id=task.id, + ), + ) + + # Collect the streaming response + aggregated_content, chunks = collect_streaming_response(stream) + + assert len(chunks) == 1 + # Get the actual content (prefer full_content if available, otherwise use aggregated) + + # Validate that "tennis" appears in the response because that is what our model does + validate_text_in_string("tennis", aggregated_content) + + states = client.states.list(task_id=task.id) + assert len(states) == 1 + + message_history = client.messages.list( + task_id=task.id, + ) + assert len(message_history) == (i + 1) * 2 # user + agent messages + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/00_sync/020_streaming/Dockerfile b/examples/tutorials/00_sync/020_streaming/Dockerfile index 586e6308..758655de 100644 --- a/examples/tutorials/00_sync/020_streaming/Dockerfile +++ b/examples/tutorials/00_sync/020_streaming/Dockerfile @@ -22,15 +22,17 @@ RUN uv pip install --system --upgrade pip setuptools wheel ENV UV_HTTP_TIMEOUT=1000 -# Copy just the requirements file to optimize caching -COPY 020_streaming/requirements.txt /app/requirements.txt +# Copy pyproject.toml and README.md to install dependencies +COPY 020_streaming/pyproject.toml /app/020_streaming/pyproject.toml +COPY 020_streaming/README.md /app/020_streaming/README.md -WORKDIR /app/ +WORKDIR /app/020_streaming -# Install the required Python packages -RUN uv pip install --system -r requirements.txt # Copy the project code -COPY 020_streaming/project /app/project +COPY 020_streaming/project /app/020_streaming/project + +# Install the required Python packages from pyproject.toml +RUN uv pip install --system . # Set environment variables ENV PYTHONPATH=/app diff --git a/examples/tutorials/00_sync/020_streaming/project/acp.py b/examples/tutorials/00_sync/020_streaming/project/acp.py index 71d1364f..18dd480a 100644 --- a/examples/tutorials/00_sync/020_streaming/project/acp.py +++ b/examples/tutorials/00_sync/020_streaming/project/acp.py @@ -29,7 +29,7 @@ class StateModel(BaseModel): # Note: The return of this handler is required to be persisted by the Agentex Server @acp.on_message_send async def handle_message_send( - params: SendMessageParams + params: SendMessageParams, ) -> Union[TaskMessageContent, AsyncGenerator[TaskMessageUpdate, None]]: """ In this tutorial, we'll see how to handle a basic multi-turn conversation without streaming. @@ -41,12 +41,12 @@ async def handle_message_send( if not params.content: return - if not hasattr(params.content, 'type') or params.content.type != "text": + if not hasattr(params.content, "type") or params.content.type != "text": raise ValueError(f"Expected text message, got {getattr(params.content, 'type', 'unknown')}") - if not hasattr(params.content, 'author') or params.content.author != "user": + if not hasattr(params.content, "author") or params.content.author != "user": raise ValueError(f"Expected user message, got {getattr(params.content, 'author', 'unknown')}") - + if not os.environ.get("OPENAI_API_KEY"): yield StreamTaskMessageFull( index=0, @@ -72,12 +72,14 @@ async def handle_message_send( llm_messages = [ SystemMessage(content=state.system_prompt), *[ - UserMessage(content=getattr(message.content, 'content', '')) if getattr(message.content, 'author', None) == "user" else AssistantMessage(content=getattr(message.content, 'content', '')) + UserMessage(content=getattr(message.content, "content", "")) + if getattr(message.content, "author", None) == "user" + else AssistantMessage(content=getattr(message.content, "content", "")) for message in task_messages - if message.content and getattr(message.content, 'type', None) == "text" - ] + if message.content and getattr(message.content, "type", None) == "text" + ], ] - + ######################################################### # 4. Call an LLM to respond to the user's message and stream the response to the client. ######################################################### diff --git a/examples/tutorials/00_sync/020_streaming/tests/test_agent.py b/examples/tutorials/00_sync/020_streaming/tests/test_agent.py new file mode 100644 index 00000000..8fe5935c --- /dev/null +++ b/examples/tutorials/00_sync/020_streaming/tests/test_agent.py @@ -0,0 +1,153 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming message sending +- Streaming message sending +- Task creation via RPC + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: s020-streaming) +""" + +import os +import pytest +from agentex import Agentex +from agentex.lib.sdk.fastacp.base.base_acp_server import uuid +from agentex.types import TextContent, TextContentParam +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest, ParamsSendMessageRequest +from test_utils.sync import collect_streaming_response, validate_text_in_string + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "s020-streaming") + + +@pytest.fixture +def client(): + """Create an AgentEx client instance for testing.""" + return Agentex(base_url=AGENTEX_API_BASE_URL) + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest.fixture +def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingMessages: + """Test non-streaming message sending.""" + + def test_send_message(self, client: Agentex, agent_name: str, agent_id: str): + """Test sending a message and receiving a response.""" + task_response = client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + + assert task is not None + + messages = [ + "Hello, can you tell me a little bit about tennis? I want you to make sure you use the word 'tennis' in each response.", + "Pick one of the things you just mentioned, and dive deeper into it.", + "Can you now output a summary of this conversation", + ] + + for i, msg in enumerate(messages): + response = client.agents.send_message( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content=msg, + type="text", + ), + task_id=task.id, + ), + ) + assert response is not None and response.result is not None + result = response.result + + for message in result: + content = message.content + assert content is not None + assert isinstance(content, TextContent) and isinstance(content.content, str) + + states = client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + + state = states[0] + assert state.state is not None + assert state.state.get("system_prompt", None) == "You are a helpful assistant that can answer questions." + message_history = client.messages.list( + task_id=task.id, + ) + assert len(message_history) == (i + 1) * 2 # user + agent messages + + +class TestStreamingMessages: + """Test streaming message sending.""" + + def test_send_stream_message(self, client: Agentex, agent_name: str, agent_id: str): + """Test streaming messages in a multi-turn conversation.""" + # create a task for this specific conversation + task_response = client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + + assert task is not None + messages = [ + "Hello, can you tell me a little bit about tennis? I want you to make sure you use the word 'tennis' in each response.", + "Pick one of the things you just mentioned, and dive deeper into it.", + "Can you now output a summary of this conversation", + ] + + for i, msg in enumerate(messages): + stream = client.agents.send_message_stream( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content=msg, + type="text", + ), + task_id=task.id, + ), + ) + + # Collect the streaming response + aggregated_content, chunks = collect_streaming_response(stream) + + assert aggregated_content is not None + # this is using the chat_completion_stream, so we will be getting chunks of data + assert len(chunks) > 1, "No chunks received in streaming response." + + states = client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + + state = states[0] + assert state.state is not None + assert state.state.get("system_prompt", None) == "You are a helpful assistant that can answer questions." + message_history = client.messages.list( + task_id=task.id, + ) + assert len(message_history) == (i + 1) * 2 # user + agent messages + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) + diff --git a/examples/tutorials/10_agentic/00_base/000_hello_acp/Dockerfile b/examples/tutorials/10_agentic/00_base/000_hello_acp/Dockerfile index 2c42e6ff..f9e26e99 100644 --- a/examples/tutorials/10_agentic/00_base/000_hello_acp/Dockerfile +++ b/examples/tutorials/10_agentic/00_base/000_hello_acp/Dockerfile @@ -22,19 +22,21 @@ RUN uv pip install --system --upgrade pip setuptools wheel ENV UV_HTTP_TIMEOUT=1000 -# Copy just the requirements file to optimize caching -COPY 000_hello_acp/requirements.txt /app/requirements.txt +# Copy pyproject.toml and README.md to install dependencies +COPY 000_hello_acp/pyproject.toml /app/000_hello_acp/pyproject.toml +COPY 000_hello_acp/README.md /app/000_hello_acp/README.md -WORKDIR /app/ - -# Install the required Python packages -RUN uv pip install --system -r requirements.txt +WORKDIR /app/000_hello_acp # Copy the project code -COPY 000_hello_acp/project /app/project +COPY 000_hello_acp/project /app/000_hello_acp/project + +# Install the required Python packages from pyproject.toml +RUN uv pip install --system . +WORKDIR /app/000_hello_acp # Set environment variables ENV PYTHONPATH=/app # Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_agentic/00_base/000_hello_acp/tests/test_agent.py b/examples/tutorials/10_agentic/00_base/000_hello_acp/tests/test_agent.py new file mode 100644 index 00000000..165adcf5 --- /dev/null +++ b/examples/tutorials/10_agentic/00_base/000_hello_acp/tests/test_agent.py @@ -0,0 +1,167 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: ab000-hello-acp) +""" + +import os +from agentex.types import TaskMessage +import pytest +import pytest_asyncio +from agentex import AsyncAgentex +from test_utils.agentic import ( + send_event_and_poll_yielding, + stream_agent_response, + poll_messages, +) + +import uuid +import asyncio +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "ab000-hello-acp") + + +@pytest_asyncio.fixture +async def client(): + """Create an AgentEx client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client: AsyncAgentex, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and polling for the response.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Poll for the initial task creation message + async for message in poll_messages( + client=client, + task_id=task.id, + timeout=30, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + if message.content and message.content.type == "text" and message.content.author == "agent": + assert "Hello! I've received your task" in message.content.content + break + + # Send an event and poll for response + user_message = "Hello, this is a test message!" + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message, + timeout=30, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + if message.content and message.content.type == "text" and message.content.author == "agent": + assert "Hello! I've received your message" in message.content.content + break + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and streaming the response.""" + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + user_message = "Hello, this is a test message!" + + # Collect events from stream + all_events = [] + + # Flags to track what we've received + task_creation_found = False + user_echo_found = False + agent_response_found = False + + async def collect_stream_events(): + nonlocal task_creation_found, user_echo_found, agent_response_found + + async for event in stream_agent_response( + client=client, + task_id=task.id, + timeout=30, + ): + all_events.append(event) + # Check events as they arrive + event_type = event.get("type") + if event_type == "full": + content = event.get("content", {}) + if content.get("content") is None: + continue # Skip empty content + if content.get("type") == "text" and content.get("author") == "agent": + # Check for initial task creation message + if "Hello! I've received your task" in content.get("content", ""): + task_creation_found = True + # Check for agent response to user message + elif "Hello! I've received your message" in content.get("content", ""): + # Agent response should come after user echo + assert user_echo_found, "Agent response arrived before user message echo (incorrect order)" + agent_response_found = True + elif content.get("type") == "text" and content.get("author") == "user": + # Check for user message echo + if content.get("content") == user_message: + user_echo_found = True + + # Exit early if we've found all expected messages + if task_creation_found and user_echo_found and agent_response_found: + break + + # Start streaming task + stream_task = asyncio.create_task(collect_stream_events()) + + # Send the event + event_content = TextContentParam(type="text", author="user", content=user_message) + await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + + # Wait for streaming to complete + await stream_task + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_agentic/00_base/010_multiturn/Dockerfile b/examples/tutorials/10_agentic/00_base/010_multiturn/Dockerfile index 3609992b..f3acc25b 100644 --- a/examples/tutorials/10_agentic/00_base/010_multiturn/Dockerfile +++ b/examples/tutorials/10_agentic/00_base/010_multiturn/Dockerfile @@ -22,19 +22,21 @@ RUN uv pip install --system --upgrade pip setuptools wheel ENV UV_HTTP_TIMEOUT=1000 -# Copy just the requirements file to optimize caching -COPY 010_multiturn/requirements.txt /app/requirements.txt +# Copy pyproject.toml and README.md to install dependencies +COPY 010_multiturn/pyproject.toml /app/010_multiturn/pyproject.toml +COPY 010_multiturn/README.md /app/010_multiturn/README.md -WORKDIR /app/ +WORKDIR /app/010_multiturn -# Install the required Python packages -RUN uv pip install --system -r requirements.txt +COPY 010_multiturn/project /app/010_multiturn/project -# Copy the project code -COPY 010_multiturn/project /app/project +# Install the required Python packages from pyproject.toml +RUN uv pip install --system . + +WORKDIR /app/010_multiturn # Set environment variables ENV PYTHONPATH=/app # Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_agentic/00_base/010_multiturn/project/acp.py b/examples/tutorials/10_agentic/00_base/010_multiturn/project/acp.py index baf797e4..fd35f91b 100644 --- a/examples/tutorials/10_agentic/00_base/010_multiturn/project/acp.py +++ b/examples/tutorials/10_agentic/00_base/010_multiturn/project/acp.py @@ -23,10 +23,11 @@ logger = make_logger(__name__) # Add a tracing processor -add_tracing_processor_config(SGPTracingProcessorConfig( - sgp_api_key=os.environ.get("SCALE_GP_API_KEY", ""), - sgp_account_id=os.environ.get("SCALE_GP_ACCOUNT_ID", "") -)) +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SCALE_GP_API_KEY", ""), sgp_account_id=os.environ.get("SCALE_GP_ACCOUNT_ID", "") + ) +) # Create an ACP server @@ -36,6 +37,7 @@ config=AgenticACPConfig(type="base"), ) + class StateModel(BaseModel): messages: List[Message] @@ -52,6 +54,7 @@ async def handle_task_create(params: CreateTaskParams): state = StateModel(messages=[SystemMessage(content="You are a helpful assistant that can answer questions.")]) await adk.state.create(task_id=params.task.id, agent_id=params.agent.id, state=state) + @acp.on_task_event_send async def handle_event_send(params: SendEventParams): # !!! Warning: Because "Agentic" ACPs are designed to be fully asynchronous, race conditions can occur if parallel events are sent. It is highly recommended to use the "temporal" type in the AgenticACPConfig instead to handle complex use cases. The "base" ACP is only designed to be used for simple use cases and for learning purposes. @@ -107,8 +110,8 @@ async def handle_event_send(params: SendEventParams): # Safely extract content from the event content_text = "" - if hasattr(params.event.content, 'content'): - content_val = getattr(params.event.content, 'content', '') + if hasattr(params.event.content, "content"): + content_val = getattr(params.event.content, "content", "") if isinstance(content_val, str): content_text = content_val state.messages.append(UserMessage(content=content_text)) @@ -128,7 +131,7 @@ async def handle_event_send(params: SendEventParams): state.messages.append(AssistantMessage(content=response_content)) ######################################################### - # 8. (๐Ÿ‘‹) Send agent response to client + # 8. (๐Ÿ‘‹) Send agent response to client ######################################################### if chat_completion.choices[0].message: @@ -157,8 +160,8 @@ async def handle_event_send(params: SendEventParams): trace_id=params.task.id, ) + @acp.on_task_cancel async def handle_task_cancel(params: CancelTaskParams): """Default task cancel handler""" logger.info(f"Task canceled: {params.task}") - diff --git a/examples/tutorials/10_agentic/00_base/010_multiturn/tests/test_agent.py b/examples/tutorials/10_agentic/00_base/010_multiturn/tests/test_agent.py new file mode 100644 index 00000000..21ba04f7 --- /dev/null +++ b/examples/tutorials/10_agentic/00_base/010_multiturn/tests/test_agent.py @@ -0,0 +1,208 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: ab010-multiturn) +""" + +from typing import List +import os +import uuid +from agentex._utils import is_iterable +from agentex.types import TaskMessage, TextContent +from agentex.types.task import Task +import pytest +import pytest_asyncio +from agentex import AsyncAgentex +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from test_utils.agentic import ( + send_event_and_poll_yielding, + stream_agent_response, +) +import asyncio +from agentex.types.text_content_param import TextContentParam + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "ab010-multiturn") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and polling for the response.""" + # TODO: Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + await asyncio.sleep(1) # wait for state to be initialized + states = await client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + + state = states[0].state + assert state is not None + messages = state.get("messages", []) + assert isinstance(messages, List) + assert len(messages) == 1 # initial message + message = messages[0] + assert message == { + "role": "system", + "content": "You are a helpful assistant that can answer questions.", + } + + user_message = "Hello! Here is my test message" + messages = [] + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message, + timeout=30, + sleep_interval=1.0, + ): + messages.append(message) + if len(messages) == 1: + assert message.content == TextContent( + author="user", + content=user_message, + type="text", + ) + else: + assert message.content is not None + assert message.content.author == "agent" + break + + await asyncio.sleep(1) # wait for state to be updated + states = await client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + state = states[0].state + messages = state.get("messages", []) + + assert isinstance(messages, list) + assert len(messages) == 3 + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_id: str): + """Test sending an event and streaming the response.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Check initial state + states = await client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + + state = states[0].state + assert state is not None + messages = state.get("messages", []) + assert isinstance(messages, List) + assert len(messages) == 1 # initial message + message = messages[0] + assert message == { + "role": "system", + "content": "You are a helpful assistant that can answer questions.", + } + user_message = "Hello! Here is my streaming test message" + + # Collect events from stream + all_events = [] + + # Flags to track what we've received + user_message_found = False + agent_response_found = False + + async def stream_messages(): + nonlocal user_message_found, agent_response_found + + async for event in stream_agent_response( + client=client, + task_id=task.id, + timeout=15, + ): + all_events.append(event) + + # Check events as they arrive + event_type = event.get("type") + if event_type == "full": + content = event.get("content", {}) + if content.get("content") == user_message and content.get("author") == "user": + # User message should come before agent response + assert not agent_response_found, "User message arrived after agent response (incorrect order)" + user_message_found = True + elif content.get("author") == "agent": + # Agent response should come after user message + assert user_message_found, "Agent response arrived before user message (incorrect order)" + agent_response_found = True + + # Exit early if we've found both messages + if user_message_found and agent_response_found: + break + + stream_task = asyncio.create_task(stream_messages()) + + event_content = TextContentParam(type="text", author="user", content=user_message) + await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + + # Wait for streaming to complete + await stream_task + + # Validate we received events + assert len(all_events) > 0, "No events received in streaming response" + assert user_message_found, "User message not found in stream" + assert agent_response_found, "Agent response not found in stream" + + # Verify the state has been updated + await asyncio.sleep(1) # wait for state to be updated + states = await client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + state = states[0].state + messages = state.get("messages", []) + + assert isinstance(messages, list) + assert len(messages) == 3 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_agentic/00_base/020_streaming/Dockerfile b/examples/tutorials/10_agentic/00_base/020_streaming/Dockerfile index 03424e58..5f593a99 100644 --- a/examples/tutorials/10_agentic/00_base/020_streaming/Dockerfile +++ b/examples/tutorials/10_agentic/00_base/020_streaming/Dockerfile @@ -22,19 +22,19 @@ RUN uv pip install --system --upgrade pip setuptools wheel ENV UV_HTTP_TIMEOUT=1000 -# Copy just the requirements file to optimize caching -COPY 020_streaming/requirements.txt /app/020_streaming/requirements.txt +# Copy pyproject.toml and README.md to install dependencies +COPY 020_streaming/pyproject.toml /app/020_streaming/pyproject.toml +COPY 020_streaming/README.md /app/020_streaming/README.md -WORKDIR /app/ +WORKDIR /app/020_streaming -# Install the required Python packages -RUN uv pip install --system -r requirements.txt - # Copy the project code -COPY 020_streaming/project /app/project +COPY 020_streaming/project /app/020_streaming/project +# Install the required Python packages from pyproject.toml +RUN uv pip install --system . # Set environment variables ENV PYTHONPATH=/app # Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_agentic/00_base/020_streaming/tests/test_agent.py b/examples/tutorials/10_agentic/00_base/020_streaming/tests/test_agent.py new file mode 100644 index 00000000..00310def --- /dev/null +++ b/examples/tutorials/10_agentic/00_base/020_streaming/tests/test_agent.py @@ -0,0 +1,210 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: ab020-streaming) +""" + +from typing import List +import os +import uuid +from agentex.types import TaskMessage, TextContent +from agentex.types.task import Task +import pytest +import pytest_asyncio +from agentex import AsyncAgentex +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from test_utils.agentic import ( + send_event_and_poll_yielding, + stream_agent_response, +) +import asyncio +from agentex.types.text_content_param import TextContentParam + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "ab020-streaming") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and polling for the response.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + await asyncio.sleep(1) # wait for state to be initialized + states = await client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + + state = states[0].state + assert state is not None + messages = state.get("messages", []) + assert isinstance(messages, List) + assert len(messages) == 1 # initial message + message = messages[0] + assert message == { + "role": "system", + "content": "You are a helpful assistant that can answer questions.", + } + + user_message = "Hello! Here is my test message" + messages = [] + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message, + timeout=30, + sleep_interval=1.0, + ): + messages.append(message) + + assert len(messages) > 0 + # the first message should be the agent re-iterating what the user sent + assert isinstance(messages, List) + assert len(messages) == 2 + first_message: TaskMessage = messages[0] + assert first_message.content == TextContent( + author="user", + content=user_message, + type="text", + ) + + second_message: TaskMessage = messages[1] + assert second_message.content is not None + assert second_message.content.author == "agent" + + # assert the state has been updated + await asyncio.sleep(1) # wait for state to be updated + states = await client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + state = states[0].state + messages = state.get("messages", []) + + assert isinstance(messages, list) + assert len(messages) == 3 + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_id: str): + """Test sending an event and streaming the response.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Check initial state + await asyncio.sleep(1) # wait for state to be initialized + states = await client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + + state = states[0].state + assert state is not None + messages = state.get("messages", []) + assert isinstance(messages, List) + assert len(messages) == 1 # initial message + message = messages[0] + assert message == { + "role": "system", + "content": "You are a helpful assistant that can answer questions.", + } + user_message = "Hello! This is my first message. Can you please tell me something interesting about yourself?" + + # Collect events from stream + all_events = [] + + async def stream_messages() -> None: + async for event in stream_agent_response( + client=client, + task_id=task.id, + timeout=15, + ): + all_events.append(event) + + stream_task = asyncio.create_task(stream_messages()) + + event_content = TextContentParam(type="text", author="user", content=user_message) + await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + + # Wait for streaming to complete + await stream_task + + # Validate we received events + assert len(all_events) > 0, "No events received in streaming response" + + # Check for user message, full agent response, and delta messages + user_message_found = False + full_agent_message_found = False + delta_messages_found = False + + for event in all_events: + event_type = event.get("type") + if event_type == "full": + content = event.get("content", {}) + if content.get("content") == user_message and content.get("author") == "user": + user_message_found = True + elif content.get("author") == "agent": + full_agent_message_found = True + elif event_type == "delta": + delta_messages_found = True + + assert user_message_found, "User message not found in stream" + assert full_agent_message_found, "Full agent message not found in stream" + assert delta_messages_found, "Delta messages not found in stream (streaming response expected)" + + # Verify the state has been updated + await asyncio.sleep(1) # wait for state to be updated + states = await client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + state = states[0].state + messages = state.get("messages", []) + + assert isinstance(messages, list) + assert len(messages) == 3 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_agentic/00_base/030_tracing/Dockerfile b/examples/tutorials/10_agentic/00_base/030_tracing/Dockerfile index 5592c5ef..78399336 100644 --- a/examples/tutorials/10_agentic/00_base/030_tracing/Dockerfile +++ b/examples/tutorials/10_agentic/00_base/030_tracing/Dockerfile @@ -22,20 +22,20 @@ RUN uv pip install --system --upgrade pip setuptools wheel ENV UV_HTTP_TIMEOUT=1000 -# Copy just the requirements file to optimize caching -COPY 030_tracing/requirements.txt /app/requirements.txt +# Copy pyproject.toml and README.md to install dependencies +COPY 030_tracing/pyproject.toml /app/030_tracing/pyproject.toml +COPY 030_tracing/README.md /app/030_tracing/README.md -WORKDIR /app/ - -# Install the required Python packages -RUN uv pip install --system -r requirements.txt +WORKDIR /app/030_tracing # Copy the project code -COPY 030_tracing/project /app/project +COPY 030_tracing/project /app/030_tracing/project +# Install the required Python packages from pyproject.toml +RUN uv pip install --system . # Set environment variables ENV PYTHONPATH=/app # Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_agentic/00_base/030_tracing/tests/test_agent.py b/examples/tutorials/10_agentic/00_base/030_tracing/tests/test_agent.py new file mode 100644 index 00000000..07e6f350 --- /dev/null +++ b/examples/tutorials/10_agentic/00_base/030_tracing/tests/test_agent.py @@ -0,0 +1,129 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: ab030-tracing) +""" + +import os +import uuid +import pytest +import pytest_asyncio +from agentex import AsyncAgentex +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from test_utils.agentic import ( + stream_agent_response, + validate_text_in_response, +) + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "ab030-tracing") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and polling for the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # TODO: Send an event and poll for response using the helper function + # messages = [] + # async for message in send_event_and_poll_yielding( + # client=client, + # agent_id=agent_id, + # task_id=task.id, + # user_message="Your test message here", + # timeout=30, + # sleep_interval=1.0, + # ): + # messages.append(message) + + # TODO: Validate the response + # assert len(messages) > 0, "No response received from agent" + # assert validate_text_in_response("expected text", messages) + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and streaming the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # TODO: Send an event and stream the response using the helper function + # all_events = [] + # + # async def collect_stream_events(): + # async for event in stream_agent_response( + # client=client, + # task_id=task.id, + # timeout=30, + # ): + # all_events.append(event) + # + # stream_task = asyncio.create_task(collect_stream_events()) + # + # event_content = TextContentParam(type="text", author="user", content="Your test message here") + # await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + # + # await stream_task + + # TODO: Validate the streaming response + # assert len(all_events) > 0, "No events received in streaming response" + # + # text_found = False + # for event in all_events: + # content = event.get("content", {}) + # if "expected text" in str(content).lower(): + # text_found = True + # break + # assert text_found, "Expected text not found in streaming response" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_agentic/00_base/040_other_sdks/Dockerfile b/examples/tutorials/10_agentic/00_base/040_other_sdks/Dockerfile index f22f269a..5761d08a 100644 --- a/examples/tutorials/10_agentic/00_base/040_other_sdks/Dockerfile +++ b/examples/tutorials/10_agentic/00_base/040_other_sdks/Dockerfile @@ -22,19 +22,20 @@ RUN uv pip install --system --upgrade pip setuptools wheel ENV UV_HTTP_TIMEOUT=1000 -# Copy just the requirements file to optimize caching -COPY 040_other_sdks/requirements.txt /app/requirements.txt +# Copy pyproject.toml and README.md to install dependencies +COPY 040_other_sdks/pyproject.toml /app/040_other_sdks/pyproject.toml +COPY 040_other_sdks/README.md /app/040_other_sdks/README.md -WORKDIR /app/ - -# Install the required Python packages -RUN uv pip install --system -r requirements.txt +WORKDIR /app/040_other_sdks # Copy the project code -COPY 040_other_sdks/project /app/project +COPY 040_other_sdks/project /app/040_other_sdks/project + +# Install the required Python packages from pyproject.toml +RUN uv pip install --system . # Set environment variables ENV PYTHONPATH=/app # Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_agentic/00_base/040_other_sdks/project/acp.py b/examples/tutorials/10_agentic/00_base/040_other_sdks/project/acp.py index 3e6467ac..fb5e4bfa 100644 --- a/examples/tutorials/10_agentic/00_base/040_other_sdks/project/acp.py +++ b/examples/tutorials/10_agentic/00_base/040_other_sdks/project/acp.py @@ -92,7 +92,6 @@ async def handle_event_send(params: SendEventParams): raise ValueError("Task state not found - ensure task was properly initialized") state = StateModel.model_validate(task_state.state) state.turn_number += 1 - # Add the new user message to the message history state.input_list.append({"role": "user", "content": params.event.content.content}) @@ -141,7 +140,8 @@ async def handle_event_send(params: SendEventParams): ) state.input_list = run_result.to_input_list() - + logger.info(f"state.input_list: {state.input_list}") + logger.info(f"state: {state}") # Store the messages in the task state for the next turn await adk.state.update( state_id=task_state.id, @@ -151,7 +151,7 @@ async def handle_event_send(params: SendEventParams): trace_id=params.task.id, parent_span_id=span.id if span else None, ) - + logger.info("successfully updated the state") # Set the span output to the state for the next turn if span: span.output = state @@ -280,6 +280,7 @@ async def run_openai_agent_with_custom_streaming( parent_task_message=streaming_context.task_message, content=tool_request_content, content_type=tool_request_content.type, + type="full", ), ) @@ -305,6 +306,7 @@ async def run_openai_agent_with_custom_streaming( parent_task_message=streaming_context.task_message, content=tool_response_content, content_type=tool_response_content.type, + type="full", ), ) @@ -338,7 +340,8 @@ async def run_openai_agent_with_custom_streaming( await streaming_context.stream_update( update=StreamTaskMessageDelta( parent_task_message=streaming_context.task_message, - delta=TextDelta(text_delta=event.data.delta), + delta=TextDelta(text_delta=event.data.delta, type="text"), + type="delta", ), ) @@ -364,7 +367,7 @@ async def run_openai_agent_with_custom_streaming( finally: # (๐Ÿ‘‹) Close all remaining streaming contexts # This will send a DONE event and update the persisted messages for all remaining streaming contents. Normally this won't be needed, but we do it in case any errors occur. - for item_id in unclosed_item_ids: + for item_id in list(unclosed_item_ids): streaming_context = item_id_to_streaming_context[item_id] await streaming_context.close() unclosed_item_ids.remove(item_id) diff --git a/examples/tutorials/10_agentic/00_base/040_other_sdks/tests/test_agent.py b/examples/tutorials/10_agentic/00_base/040_other_sdks/tests/test_agent.py new file mode 100644 index 00000000..246266e3 --- /dev/null +++ b/examples/tutorials/10_agentic/00_base/040_other_sdks/tests/test_agent.py @@ -0,0 +1,402 @@ +""" +Sample tests for AgentEx ACP agent with MCP servers and custom streaming. + +This test suite demonstrates how to test agents that integrate: +- OpenAI Agents SDK with streaming +- MCP (Model Context Protocol) servers for tool access +- Custom streaming patterns (delta-based and full messages) +- Complex multi-turn conversations with tool usage + +Key differences from regular streaming (020_streaming): +1. MCP Integration: Agent has access to external tools via MCP servers (sequential-thinking, web-search) +2. Tool Call Streaming: Tests both tool request and tool response streaming patterns +3. Mixed Streaming: Combines full message streaming (tools) with delta streaming (text) +4. Advanced State: Tracks turn_number and input_list instead of simple message history +5. Custom Streaming Context: Manual lifecycle management for different message types + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Ensure OPENAI_API_KEY is set in the environment +4. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: ab040-other-sdks) +""" + +from curses import ALL_MOUSE_EVENTS +from typing import List +import os +import uuid +from agentex.types import TaskMessage, TextContent +from agentex.types.task import Task +import pytest +import pytest_asyncio +from agentex import AsyncAgentex +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from test_utils.agentic import ( + send_event_and_poll_yielding, + stream_agent_response, +) +import asyncio +from agentex.types.text_content_param import TextContentParam +from datetime import datetime + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "ab040-other-sdks") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling with MCP tools.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll_simple_query(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending a simple event and polling for the response (no tool use).""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Check initial state - should have empty input_list and turn_number 0 + await asyncio.sleep(1) # wait for state to be initialized + states = await client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + + state = states[0].state + assert state is not None + assert state.get("input_list", []) == [] + assert state.get("turn_number", 0) == 0 + + # Send a simple message that shouldn't require tool use + user_message = "Hello! Please introduce yourself briefly." + messages = [] + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message, + timeout=30, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + messages.append(message) + + if len(messages) == 1: + assert message.content == TextContent( + author="user", + content=user_message, + type="text", + ) + break + + # Verify state has been updated by polling the states for 10 seconds + for i in range(10): + if i == 9: + raise Exception("Timeout waiting for state updates") + states = await client.states.list(agent_id=agent_id, task_id=task.id) + state = states[0].state + if len(state.get("input_list", [])) > 0 and state.get("turn_number") == 1: + break + await asyncio.sleep(1) + + states = await client.states.list(agent_id=agent_id, task_id=task.id) + state = states[0].state + assert state.get("turn_number") == 1 + + @pytest.mark.asyncio + async def test_send_event_and_poll_with_tool_use(self, client: AsyncAgentex, agent_id: str): + """Test sending an event that triggers tool usage and polling for the response.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Send a message that should trigger the sequential-thinking tool + user_message = "What is 15 multiplied by 37? Please think through this step by step." + tool_request_found = False + tool_response_found = False + has_final_agent_response = False + + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message, + timeout=60, # Longer timeout for tool use + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + if message.content and message.content.type == "tool_request": + tool_request_found = True + assert message.content.author == "agent" + assert hasattr(message.content, "name") + assert hasattr(message.content, "tool_call_id") + elif message.content and message.content.type == "tool_response": + tool_response_found = True + assert message.content.author == "agent" + elif message.content and message.content.type == "text" and message.content.author == "agent": + has_final_agent_response = True + break + + assert has_final_agent_response, "Did not receive final agent text response" + assert tool_request_found, "Did not see tool request message" + assert tool_response_found, "Did not see tool response message" + + @pytest.mark.asyncio + async def test_multi_turn_conversation_with_state(self, client: AsyncAgentex, agent_id: str): + """Test multiple turns of conversation with state preservation.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # ensure the task is created before we send the first event + await asyncio.sleep(1) + # First turn + user_message_1 = "My favorite color is blue." + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message_1, + timeout=20, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + if message.content and message.content.type == "text" and message.content.author == "agent" and message.content.content: + break + + ## keep polling the states for 10 seconds for the input_list and turn_number to be updated + for i in range(30): + if i == 29: + raise Exception("Timeout waiting for state updates") + states = await client.states.list(agent_id=agent_id, task_id=task.id) + state = states[0].state + if len(state.get("input_list", [])) > 0 and state.get("turn_number") == 1: + break + await asyncio.sleep(1) + + states = await client.states.list(agent_id=agent_id, task_id=task.id) + state = states[0].state + assert state.get("turn_number") == 1 + + await asyncio.sleep(1) + found_response = False + # Second turn - reference previous context + user_message_2 = "What did I just tell you my favorite color was?" + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message_2, + timeout=30, + sleep_interval=1.0, + ): + if message.content and message.content.type == "text" and message.content.author == "agent" and message.content.content: + response_text = message.content.content.lower() + assert "blue" in response_text + found_response = True + break + + assert found_response, "Did not receive final agent text response" + for i in range(10): + if i == 9: + raise Exception("Timeout waiting for state updates") + states = await client.states.list(agent_id=agent_id, task_id=task.id) + state = states[0].state + if len(state.get("input_list", [])) > 0 and state.get("turn_number") == 2: + break + await asyncio.sleep(1) + + states = await client.states.list(agent_id=agent_id, task_id=task.id) + state = states[0].state + assert state.get("turn_number") == 2 + + +class TestStreamingEvents: + """Test streaming event sending with MCP tools and custom streaming patterns.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream_simple(self, client: AsyncAgentex, agent_id: str): + """Test streaming a simple response without tool usage.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Check initial state + await asyncio.sleep(1) # wait for state to be initialized + states = await client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + state = states[0].state + assert state.get("input_list", []) == [] + assert state.get("turn_number", 0) == 0 + + user_message = "Tell me a very short joke about programming." + + # Collect events from stream + # Check for user message and delta messages + user_message_found = False + + async def stream_messages() -> None: + nonlocal user_message_found + async for event in stream_agent_response( + client=client, + task_id=task.id, + timeout=20, + ): + msg_type = event.get("type") + # For full messages, content is at the top level + # For delta messages, we need to check parent_task_message + if msg_type == "full": + if event.get("content", {}).get("type") == "text" and event.get("content", {}).get("author") == "user": + user_message_found = True + elif msg_type == "done": + break + + stream_task = asyncio.create_task(stream_messages()) + + event_content = TextContentParam(type="text", author="user", content=user_message) + await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + + # Wait for streaming to complete + await stream_task + assert user_message_found, "User message found in stream" + ## keep polling the states for 10 seconds for the input_list and turn_number to be updated + for i in range(10): + if i == 9: + raise Exception("Timeout waiting for state updates") + states = await client.states.list(agent_id=agent_id, task_id=task.id) + state = states[0].state + if len(state.get("input_list", [])) > 0 and state.get("turn_number") == 1: + break + await asyncio.sleep(1) + + # Verify state has been updated + states = await client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + state = states[0].state + input_list = state.get("input_list", []) + + assert isinstance(input_list, list) + assert len(input_list) >= 2 + assert state.get("turn_number") == 1 + + @pytest.mark.asyncio + async def test_send_event_and_stream_with_tools(self, client: AsyncAgentex, agent_id: str): + """Test streaming with tool calls - demonstrates mixed streaming patterns.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # This query should trigger tool usage + user_message = "Use sequential thinking to calculate what 123 times 456 equals." + + tool_requests_seen = [] + tool_responses_seen = [] + text_deltas_seen = [] + + async def stream_messages() -> None: + nonlocal tool_requests_seen, tool_responses_seen, text_deltas_seen + + async for event in stream_agent_response( + client=client, + task_id=task.id, + timeout=45, + ): + msg_type = event.get("type") + + # For full messages, content is at the top level + # For delta messages, we need to check parent_task_message + if msg_type == "delta": + parent_msg = event.get("parent_task_message", {}) + content = parent_msg.get("content", {}) + delta = event.get("delta", {}) + content_type = content.get("type") + + if content_type == "text": + text_deltas_seen.append(delta.get("text_delta", "")) + elif msg_type == "full": + # For full messages + content = event.get("content", {}) + content_type = content.get("type") + + if content_type == "tool_request": + tool_requests_seen.append( + { + "name": content.get("name"), + "tool_call_id": content.get("tool_call_id"), + "streaming_type": msg_type, + } + ) + elif content_type == "tool_response": + tool_responses_seen.append( + { + "tool_call_id": content.get("tool_call_id"), + "streaming_type": msg_type, + } + ) + elif msg_type == "done": + break + + stream_task = asyncio.create_task(stream_messages()) + + event_content = TextContentParam(type="text", author="user", content=user_message) + await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + + # Wait for streaming to complete + await stream_task + + # Verify we saw tool usage (if the agent decided to use tools) + # Note: The agent may or may not use tools depending on its reasoning + # Verify the state has a response written to it + # assert len(text_deltas_seen) > 0, "Should have received text delta streaming" + for i in range(10): + if i == 9: + raise Exception("Timeout waiting for state updates") + states = await client.states.list(agent_id=agent_id, task_id=task.id) + state = states[0].state + if len(state.get("input_list", [])) > 0 and state.get("turn_number") == 1: + break + await asyncio.sleep(1) + + # Verify state has been updated + states = await client.states.list(agent_id=agent_id, task_id=task.id) + assert len(states) == 1 + state = states[0].state + input_list = state.get("input_list", []) + + assert isinstance(input_list, list) + assert len(input_list) >= 2 + print(input_list) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_agentic/00_base/080_batch_events/Dockerfile b/examples/tutorials/10_agentic/00_base/080_batch_events/Dockerfile index e006a25d..352fa522 100644 --- a/examples/tutorials/10_agentic/00_base/080_batch_events/Dockerfile +++ b/examples/tutorials/10_agentic/00_base/080_batch_events/Dockerfile @@ -22,19 +22,27 @@ RUN uv pip install --system --upgrade pip setuptools wheel ENV UV_HTTP_TIMEOUT=1000 -# Copy just the requirements file to optimize caching -COPY 080_batch_events/requirements.txt /app/requirements.txt +# Copy pyproject.toml and README.md to install dependencies +COPY 080_batch_events/pyproject.toml /app/080_batch_events/pyproject.toml +COPY 080_batch_events/README.md /app/080_batch_events/README.md -WORKDIR /app/ - -# Install the required Python packages -RUN uv pip install --system -r requirements.txt +WORKDIR /app/080_batch_events # Copy the project code -COPY 080_batch_events/project /app/project +COPY 080_batch_events/project /app/080_batch_events/project + +# Copy test files +COPY 080_batch_events/test_*.py /app/080_batch_events/ + +# Install the required Python packages from pyproject.toml +RUN uv pip install --system . + +# Install pytest for running tests +RUN uv pip install --system pytest pytest-asyncio +WORKDIR /app/080_batch_events # Set environment variables ENV PYTHONPATH=/app # Run the agent using uvicorn -CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_agentic/00_base/080_batch_events/manifest.yaml b/examples/tutorials/10_agentic/00_base/080_batch_events/manifest.yaml index 29b78932..314792be 100644 --- a/examples/tutorials/10_agentic/00_base/080_batch_events/manifest.yaml +++ b/examples/tutorials/10_agentic/00_base/080_batch_events/manifest.yaml @@ -15,24 +15,24 @@ build: context: # Root directory for the build context - root: ../../../ # Keep this as the default root + root: ../ # Keep this as the default root # Paths to include in the Docker build context # Must include: # - Your agent's directory (your custom agent code) # These paths are collected and sent to the Docker daemon for building include_paths: - - 10_agentic/00_base/080_batch_events + - 080_batch_events # Path to your agent's Dockerfile # This defines how your agent's image is built from the context # Relative to the root directory - dockerfile: 10_agentic/00_base/080_batch_events/Dockerfile + dockerfile: 080_batch_events/Dockerfile # Path to your agent's .dockerignore # Filters unnecessary files from the build context # Helps keep build context small and builds fast - dockerignore: 10_agentic/00_base/080_batch_events/.dockerignore + dockerignore: 080_batch_events/.dockerignore # Local Development Configuration diff --git a/examples/tutorials/10_agentic/00_base/080_batch_events/project/acp.py b/examples/tutorials/10_agentic/00_base/080_batch_events/project/acp.py index 4b00e352..fd74cd04 100644 --- a/examples/tutorials/10_agentic/00_base/080_batch_events/project/acp.py +++ b/examples/tutorials/10_agentic/00_base/080_batch_events/project/acp.py @@ -44,7 +44,7 @@ async def process_events_batch(events, task_id: str) -> str: # Sleep for 2s per event to simulate processing work for event in events: - await asyncio.sleep(5) + await asyncio.sleep(3) logger.info(f" INSIDE PROCESSING LOOP - FINISHED PROCESSING EVENT {event.id}") # Create message showing what was processed diff --git a/examples/tutorials/10_agentic/00_base/080_batch_events/tests/test_agent.py b/examples/tutorials/10_agentic/00_base/080_batch_events/tests/test_agent.py new file mode 100644 index 00000000..9ee903ca --- /dev/null +++ b/examples/tutorials/10_agentic/00_base/080_batch_events/tests/test_agent.py @@ -0,0 +1,222 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: ab080-batch-events) +""" + +import os +import uuid +from agentex.types import TaskMessage +import pytest +import pytest_asyncio +from agentex import AsyncAgentex +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from test_utils.agentic import ( + send_event_and_poll_yielding, + stream_agent_response, + validate_text_in_response, +) +import asyncio +from agentex.types.text_content_param import TextContentParam +from agentex.types.task_message_content import TextContent +import re + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "ab080-batch-events") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending a single event and polling for the response.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Send an event and poll for response using the helper function + # there should only be one message returned about batching + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message="Process this single event", + timeout=30, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + assert isinstance(message.content, TextContent) + assert "Processed event IDs" in message.content.content + assert message.content.author == "agent" + break + + @pytest.mark.asyncio + async def test_send_multiple_events_batched(self, client: AsyncAgentex, agent_id: str): + """Test sending multiple events that should be batched together.""" + # Create a task + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Send multiple events in quick succession (should be batched) + num_events = 7 + for i in range(num_events): + event_content = TextContentParam(type="text", author="user", content=f"Batch event {i + 1}") + await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + await asyncio.sleep(0.1) # Small delay to ensure ordering + + # Wait for processing to complete (5 events * 5 seconds each = 25s + buffer) + + ## there should be at least 2 agent responses to ensure that not all of the events are processed + ## in the same message + agent_messages = [] + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message="Process this single event", + timeout=30, + sleep_interval=1.0, + ): + if message.content and message.content.author == "agent": + agent_messages.append(message) + + if len(agent_messages) == 2: + break + + assert len(agent_messages) > 0, "Should have received at least one agent response" + + # PROOF OF BATCHING: Should have fewer responses than events sent + assert len(agent_messages) < num_events, ( + f"Expected batching to result in fewer responses than {num_events} events, got {len(agent_messages)}" + ) + + # Analyze each batch response to count how many events were in each batch + found_batch_with_multiple_events = False + for msg in agent_messages: + assert isinstance(msg.content, TextContent) + response = msg.content.content + + # Count event IDs in this response (they're in a list like ['id1', 'id2', ...]) + # Use regex to find all quoted strings in the list + event_ids = re.findall(r"'([^']+)'", response) + batch_size = len(event_ids) + if batch_size > 1: + # this measn that we have found a batch with multiple events + found_batch_with_multiple_events = True + break + + assert found_batch_with_multiple_events, "Should have found a batch with multiple events" + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_twenty_events_batched_streaming(self, client: AsyncAgentex, agent_id: str): + """Test sending 20 events and verifying batch processing via streaming.""" + # Create a task + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Send 10 events in quick succession (should be batched) + num_events = 10 + print(f"\nSending {num_events} events in quick succession...") + for i in range(num_events): + event_content = TextContentParam(type="text", author="user", content=f"Batch event {i + 1}") + await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + await asyncio.sleep(0.1) # Small delay to ensure ordering + + # Stream the responses and collect agent messages + print("\nStreaming batch responses...") + + # We'll collect all agent messages from the stream + agent_messages = [] + stream_timeout = 90 # Longer timeout for 20 events + + async for event in stream_agent_response( + client=client, + task_id=task.id, + timeout=stream_timeout, + ): + # Collect agent text messages + if event.get("type") == "full": + content = event.get("content", {}) + if content.get("type") == "text" and content.get("author") == "agent": + msg_content = content.get("content", "") + if msg_content and msg_content.strip(): + agent_messages.append(msg_content) + + if len(agent_messages) >= 2: + break + + print(f"\nSent {num_events} events") + print(f"Received {len(agent_messages)} agent response(s)") + + assert len(agent_messages) > 0, "Should have received at least one agent response" + + # PROOF OF BATCHING: Should have fewer responses than events sent + assert len(agent_messages) < num_events, ( + f"Expected batching to result in fewer responses than {num_events} events, got {len(agent_messages)}" + ) + + # Analyze each batch response to count how many events were in each batch + total_events_processed = 0 + found_batch_with_multiple_events = False + for response in agent_messages: + # Count event IDs in this response (they're in a list like ['id1', 'id2', ...]) + # Use regex to find all quoted strings in the list + event_ids = re.findall(r"'([^']+)'", response) + batch_size = len(event_ids) + + total_events_processed += batch_size + + # At least one response should have multiple events (proof of batching) + if batch_size > 1: + found_batch_with_multiple_events = True + break + + assert found_batch_with_multiple_events, "Should have found a batch with multiple events" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_agentic/00_base/090_multi_agent_non_temporal/Dockerfile b/examples/tutorials/10_agentic/00_base/090_multi_agent_non_temporal/Dockerfile index 742adfe4..9eab5940 100644 --- a/examples/tutorials/10_agentic/00_base/090_multi_agent_non_temporal/Dockerfile +++ b/examples/tutorials/10_agentic/00_base/090_multi_agent_non_temporal/Dockerfile @@ -23,16 +23,17 @@ RUN uv pip install --system --upgrade pip setuptools wheel ENV UV_HTTP_TIMEOUT=1000 -# Copy just the pyproject.toml file to optimize caching -COPY pyproject.toml /app/pyproject.toml -WORKDIR /app/ -# Install the required Python packages -RUN uv pip install --system -e . +# Copy pyproject.toml and README.md to install dependencies +COPY 090_multi_agent_non_temporal/pyproject.toml /app/090_multi_agent_non_temporal/pyproject.toml +COPY 090_multi_agent_non_temporal/README.md /app/090_multi_agent_non_temporal/README.md + +WORKDIR /app/090_multi_agent_non_temporal # Copy the project code -COPY project /app/project +COPY 090_multi_agent_non_temporal/project /app/090_multi_agent_non_temporal/project -WORKDIR /app +# Install the required Python packages from pyproject.toml +RUN uv pip install --system . # Set environment variables ENV PYTHONPATH=/app diff --git a/examples/tutorials/10_agentic/00_base/090_multi_agent_non_temporal/tests/test_agent.py b/examples/tutorials/10_agentic/00_base/090_multi_agent_non_temporal/tests/test_agent.py new file mode 100644 index 00000000..cad98b29 --- /dev/null +++ b/examples/tutorials/10_agentic/00_base/090_multi_agent_non_temporal/tests/test_agent.py @@ -0,0 +1,240 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: ab090-orchestrator-agent) +""" + +import os +import uuid +import pytest +import pytest_asyncio +from agentex import AsyncAgentex +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from test_utils.agentic import ( + send_event_and_poll_yielding, + stream_agent_response, +) +from agentex.types.text_content_param import TextContentParam + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "ab090-orchestrator-agent") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_multi_agent_workflow_complete(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test the complete multi-agent workflow with all agents using polling that yields messages.""" + # Create a task for the orchestrator + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Send a content creation request as JSON + request_json = { + "request": "Write a welcome message for our AI assistant", + "rules": ["Under 50 words", "Friendly tone", "Include emoji"], + "target_format": "HTML", + } + + import json + + # Collect messages as they arrive from polling + messages = [] + print("\n๐Ÿ”„ Polling for multi-agent workflow responses...") + + # Track which agents have completed their work + workflow_markers = { + "orchestrator_started": False, + "creator_called": False, + "critic_called": False, + "formatter_called": False, + "workflow_completed": False, + } + + all_agents_done = False + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=json.dumps(request_json), + timeout=120, # Longer timeout for multi-agent workflow + sleep_interval=2.0, + ): + messages.append(message) + # Print messages as they arrive to show real-time progress + if message.content and message.content.content: + # Track agent participation as messages arrive + content = message.content.content.lower() + + if "starting content workflow" in content: + workflow_markers["orchestrator_started"] = True + + if "creator output" in content: + workflow_markers["creator_called"] = True + + if "critic feedback" in content or "content approved by critic" in content: + workflow_markers["critic_called"] = True + + if "calling formatter agent" in content: + workflow_markers["formatter_called"] = True + + if "workflow complete" in content or "content creation complete" in content: + workflow_markers["workflow_completed"] = True + + # Check if all agents have participated + all_agents_done = all(workflow_markers.values()) + if all_agents_done: + break + + # Assert all agents participated + assert workflow_markers["orchestrator_started"], "Orchestrator did not start workflow" + assert workflow_markers["creator_called"], "Creator agent was not called" + assert workflow_markers["critic_called"], "Critic agent was not called" + assert workflow_markers["formatter_called"], "Formatter agent was not called" + assert workflow_markers["workflow_completed"], "Workflow did not complete successfully" + + assert all_agents_done, "Not all agents completed their work before timeout" + + # Verify the final output contains HTML (since we requested HTML format) + all_messages_text = " ".join([msg.content.content for msg in messages if msg.content]) + assert "" in all_messages_text.lower() or " 0, "No messages received from streaming" + + # Assert all agents participated + assert workflow_markers["orchestrator_started"], "Orchestrator did not start workflow" + assert workflow_markers["creator_called"], "Creator agent was not called" + assert workflow_markers["critic_called"], "Critic agent was not called" + assert workflow_markers["formatter_called"], "Formatter agent was not called" + assert workflow_markers["workflow_completed"], "Workflow did not complete successfully" + + # Verify the final output contains Markdown (since we requested Markdown format) + combined_response = " ".join(all_messages) + assert "markdown" in combined_response.lower() or "#" in combined_response, ( + "Final output does not contain Markdown formatting" + ) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_agentic/10_temporal/000_hello_acp/Dockerfile b/examples/tutorials/10_agentic/10_temporal/000_hello_acp/Dockerfile index 248e41cc..3c835664 100644 --- a/examples/tutorials/10_agentic/10_temporal/000_hello_acp/Dockerfile +++ b/examples/tutorials/10_agentic/10_temporal/000_hello_acp/Dockerfile @@ -28,16 +28,17 @@ RUN uv pip install --system --upgrade pip setuptools wheel ENV UV_HTTP_TIMEOUT=1000 -# Copy just the requirements file to optimize caching -COPY 000_hello_acp/requirements.txt /app/requirements.txt +# Copy pyproject.toml and README.md to install dependencies +COPY 000_hello_acp/pyproject.toml /app/000_hello_acp/pyproject.toml +COPY 000_hello_acp/README.md /app/000_hello_acp/README.md -WORKDIR /app/ - -# Install the required Python packages -RUN uv pip install --system -r requirements.txt +WORKDIR /app/000_hello_acp # Copy the project code -COPY 000_hello_acp/project /app/project +COPY 000_hello_acp/project /app/000_hello_acp/project + +# Install the required Python packages from pyproject.toml +RUN uv pip install --system . # Run the ACP server using uvicorn CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_agentic/10_temporal/000_hello_acp/tests/test_agent.py b/examples/tutorials/10_agentic/10_temporal/000_hello_acp/tests/test_agent.py new file mode 100644 index 00000000..83729186 --- /dev/null +++ b/examples/tutorials/10_agentic/10_temporal/000_hello_acp/tests/test_agent.py @@ -0,0 +1,170 @@ +""" +Sample tests for AgentEx ACP agent (Temporal version). + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: at000-hello-acp) +""" + +import os +from agentex.types import TaskMessage +import pytest +import pytest_asyncio +from agentex import AsyncAgentex +from test_utils.agentic import ( + send_event_and_poll_yielding, + stream_agent_response, + poll_messages, +) + +import uuid +import asyncio +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "at000-hello-acp") + + +@pytest_asyncio.fixture +async def client(): + """Create an AgentEx client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client: AsyncAgentex, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and polling for the response.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Poll for the initial task creation message + async for message in poll_messages( + client=client, + task_id=task.id, + timeout=30, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + if message.content and message.content.type == "text" and message.content.author == "agent": + assert "Hello! I've received your task" in message.content.content + break + + await asyncio.sleep(1.5) + # Send an event and poll for response + user_message = "Hello, this is a test message!" + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message, + timeout=30, + sleep_interval=1.0, + ): + if message.content and message.content.type == "text" and message.content.author == "agent": + assert "Hello! I've received your message" in message.content.content + break + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and streaming the response.""" + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + user_message = "Hello, this is a test message!" + + # Collect events from stream + all_events = [] + + # Flags to track what we've received + task_creation_found = False + user_echo_found = False + agent_response_found = False + + async def collect_stream_events(): #noqa: ANN101 + nonlocal task_creation_found, user_echo_found, agent_response_found + + async for event in stream_agent_response( + client=client, + task_id=task.id, + timeout=30, + ): + # Check events as they arrive + event_type = event.get("type") + if event_type == "full": + content = event.get("content", {}) + if content.get("content") is None: + continue # Skip empty content + if content.get("type") == "text" and content.get("author") == "agent": + # Check for initial task creation message + if "Hello! I've received your task" in content.get("content", ""): + task_creation_found = True + # Check for agent response to user message + elif "Hello! I've received your message" in content.get("content", ""): + # Agent response should come after user echo + assert user_echo_found, "Agent response arrived before user message echo (incorrect order)" + agent_response_found = True + elif content.get("type") == "text" and content.get("author") == "user": + # Check for user message echo + if content.get("content") == user_message: + user_echo_found = True + + # Exit early if we've found all expected messages + if task_creation_found and user_echo_found and agent_response_found: + break + + assert task_creation_found, "Task creation message not found in stream" + assert user_echo_found, "User message echo not found in stream" + assert agent_response_found, "Agent response not found in stream" + + + # Start streaming task + stream_task = asyncio.create_task(collect_stream_events()) + + # Send the event + event_content = TextContentParam(type="text", author="user", content=user_message) + await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + + # Wait for streaming to complete + await stream_task + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/examples/tutorials/10_agentic/10_temporal/010_agent_chat/Dockerfile b/examples/tutorials/10_agentic/10_temporal/010_agent_chat/Dockerfile index eda1cf62..16772e4a 100644 --- a/examples/tutorials/10_agentic/10_temporal/010_agent_chat/Dockerfile +++ b/examples/tutorials/10_agentic/10_temporal/010_agent_chat/Dockerfile @@ -28,16 +28,17 @@ RUN uv pip install --system --upgrade pip setuptools wheel ENV UV_HTTP_TIMEOUT=1000 -# Copy just the requirements file to optimize caching -COPY 010_agent_chat/requirements.txt /app/requirements.txt +# Copy pyproject.toml and README.md to install dependencies +COPY 010_agent_chat/pyproject.toml /app/010_agent_chat/pyproject.toml +COPY 010_agent_chat/README.md /app/010_agent_chat/README.md -WORKDIR /app/ - -# Install the required Python packages -RUN uv pip install --system -r requirements.txt +WORKDIR /app/010_agent_chat # Copy the project code -COPY 010_agent_chat/project /app/project +COPY 010_agent_chat/project /app/010_agent_chat/project + +# Install the required Python packages from pyproject.toml +RUN uv pip install --system . # Run the ACP server using uvicorn CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_agentic/10_temporal/010_agent_chat/pyproject.toml b/examples/tutorials/10_agentic/10_temporal/010_agent_chat/pyproject.toml index d3815934..799fa5fe 100644 --- a/examples/tutorials/10_agentic/10_temporal/010_agent_chat/pyproject.toml +++ b/examples/tutorials/10_agentic/10_temporal/010_agent_chat/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "agentex-sdk", "debugpy>=1.8.15", "scale-gp", + "yaspin>=3.1.0", ] [project.optional-dependencies] diff --git a/examples/tutorials/10_agentic/10_temporal/010_agent_chat/tests/test_agent.py b/examples/tutorials/10_agentic/10_temporal/010_agent_chat/tests/test_agent.py new file mode 100644 index 00000000..e57d90e7 --- /dev/null +++ b/examples/tutorials/10_agentic/10_temporal/010_agent_chat/tests/test_agent.py @@ -0,0 +1,249 @@ +""" +Sample tests for AgentEx Temporal agent with OpenAI Agents SDK integration. + +This test suite demonstrates how to test agents that integrate: +- OpenAI Agents SDK with streaming (via Temporal workflows) +- MCP (Model Context Protocol) servers for tool access +- Multi-turn conversations with state management +- Tool usage (calculator and web search via MCP) + +Key differences from base agentic (040_other_sdks): +1. Temporal Integration: Uses Temporal workflows for durable execution +2. State Management: State is managed within the workflow instance +3. No Race Conditions: Temporal ensures sequential event processing +4. Durable Execution: Workflow state survives restarts + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Ensure OPENAI_API_KEY is set in the environment +4. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: at010-agent-chat) +""" + +from agentex.lib.utils.dev_tools.async_messages import subscribe_to_async_task_messages +from agentex.types.agent_rpc_result import StreamTaskMessageDone +from agentex.types.agent_rpc_result import StreamTaskMessageFull +from typing import List +import os +import uuid +from agentex.types import TaskMessage, TextContent +from agentex.types.task import Task +import pytest +import pytest_asyncio +from agentex import AsyncAgentex +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from test_utils.agentic import ( + send_event_and_poll_yielding, + stream_agent_response, +) +import asyncio +from agentex.types.text_content_param import TextContentParam + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "at010-agent-chat") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling with OpenAI Agents SDK.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll_simple_query(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending a simple event and polling for the response (no tool use).""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Wait for workflow to initialize + await asyncio.sleep(1) + + # Send a simple message that shouldn't require tool use + user_message = "Hello! Please introduce yourself briefly." + messages = [] + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message, + timeout=30, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + messages.append(message) + + if len(messages) == 1: + assert message.content == TextContent( + author="user", + content=user_message, + type="text", + ) + break + + @pytest.mark.asyncio + async def test_send_event_and_poll_with_calculator(self, client: AsyncAgentex, agent_id: str): + """Test sending an event that triggers calculator tool usage and polling for the response.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Wait for workflow to initialize + await asyncio.sleep(1) + + # Send a message that could trigger the calculator tool (though with reasoning, it may not need it) + user_message = "What is 15 multiplied by 37?" + has_final_agent_response = False + + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message, + timeout=60, # Longer timeout for tool use + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + if message.content and message.content.type == "text" and message.content.author == "agent": + # Check that the answer contains 555 (15 * 37) + if "555" in message.content.content: + has_final_agent_response = True + break + + assert has_final_agent_response, "Did not receive final agent text response with correct answer" + + @pytest.mark.asyncio + async def test_multi_turn_conversation(self, client: AsyncAgentex, agent_id: str): + """Test multiple turns of conversation with state preservation.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Wait for workflow to initialize + await asyncio.sleep(1) + + # First turn + user_message_1 = "My favorite color is blue." + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message_1, + timeout=20, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + if message.content and message.content.type == "text" and message.content.author == "agent" and message.content.content: + break + + # Wait a bit for state to update + await asyncio.sleep(2) + + # Second turn - reference previous context + found_response = False + user_message_2 = "What did I just tell you my favorite color was?" + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message_2, + timeout=30, + sleep_interval=1.0, + ): + if message.content and message.content.type == "text" and message.content.author == "agent" and message.content.content: + response_text = message.content.content.lower() + assert "blue" in response_text, f"Expected 'blue' in response but got: {response_text}" + found_response = True + break + + assert found_response, "Did not receive final agent text response with context recall" + + +class TestStreamingEvents: + """Test streaming event sending with OpenAI Agents SDK and tool usage.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream_with_reasoning(self, client: AsyncAgentex, agent_id: str): + """Test streaming a simple response without tool usage.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Wait for workflow to initialize + await asyncio.sleep(1) + + user_message = "Tell me a very short joke about programming." + + # Check for user message and agent response + user_message_found = False + agent_response_found = False + + async def stream_messages() -> None: # noqa: ANN101 + nonlocal user_message_found, agent_response_found + async for event in stream_agent_response( + client=client, + task_id=task.id, + timeout=20, + ): + msg_type = event.get("type") + if msg_type == "full": + task_message_update = StreamTaskMessageFull.model_validate(event) + if task_message_update.parent_task_message and task_message_update.parent_task_message.id: + finished_message = await client.messages.retrieve(task_message_update.parent_task_message.id) + if finished_message.content and finished_message.content.type == "text" and finished_message.content.author == "user": + user_message_found = True + elif finished_message.content and finished_message.content.type == "text" and finished_message.content.author == "agent": + agent_response_found = True + elif finished_message.content and finished_message.content.type == "reasoning": + tool_response_found = True + elif msg_type == "done": + task_message_update = StreamTaskMessageDone.model_validate(event) + if task_message_update.parent_task_message and task_message_update.parent_task_message.id: + finished_message = await client.messages.retrieve(task_message_update.parent_task_message.id) + if finished_message.content and finished_message.content.type == "reasoning": + agent_response_found = True + continue + + stream_task = asyncio.create_task(stream_messages()) + + event_content = TextContentParam(type="text", author="user", content=user_message) + await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + + # Wait for streaming to complete + await stream_task + + assert user_message_found, "User message not found in stream" + assert agent_response_found, "Agent response not found in stream" + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_agentic/10_temporal/010_agent_chat/uv.lock b/examples/tutorials/10_agentic/10_temporal/010_agent_chat/uv.lock index 60b1d7b7..6936bd95 100644 --- a/examples/tutorials/10_agentic/10_temporal/010_agent_chat/uv.lock +++ b/examples/tutorials/10_agentic/10_temporal/010_agent_chat/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.13'", @@ -175,6 +175,7 @@ dependencies = [ { name = "agentex-sdk" }, { name = "debugpy" }, { name = "scale-gp" }, + { name = "yaspin" }, ] [package.optional-dependencies] @@ -194,6 +195,7 @@ requires-dist = [ { name = "isort", marker = "extra == 'dev'" }, { name = "pytest", marker = "extra == 'dev'" }, { name = "scale-gp" }, + { name = "yaspin", specifier = ">=3.1.0" }, ] provides-extras = ["dev"] @@ -2048,6 +2050,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/2b/ba962401324892236148046dbffd805d4443d6df7a7dc33cc7964b566bf9/temporalio-1.15.0-cp39-abi3-win_amd64.whl", hash = "sha256:aae5b18d7c9960238af0f3ebf6b7e5959e05f452106fc0d21a8278d78724f780", size = 12932800, upload-time = "2025-07-29T03:44:06.271Z" }, ] +[[package]] +name = "termcolor" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/6c/3d75c196ac07ac8749600b60b03f4f6094d54e132c4d94ebac6ee0e0add0/termcolor-3.1.0.tar.gz", hash = "sha256:6a6dd7fbee581909eeec6a756cff1d7f7c376063b14e4a298dc4980309e55970", size = 14324, upload-time = "2025-04-30T11:37:53.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/bd/de8d508070629b6d84a30d01d57e4a65c69aa7f5abe7560b8fad3b50ea59/termcolor-3.1.0-py3-none-any.whl", hash = "sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa", size = 7684, upload-time = "2025-04-30T11:37:52.382Z" }, +] + [[package]] name = "tiktoken" version = "0.11.0" @@ -2414,6 +2425,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, ] +[[package]] +name = "yaspin" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "termcolor" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/cd/3d2877a5558fdad6de4166fa0160dc49cb1382820cd955753c67b56facd2/yaspin-3.3.0.tar.gz", hash = "sha256:505c9a44c6e3723a1bee8f7a17a055b17475176b74dd93e468fa8db48c172a41", size = 42411, upload-time = "2025-10-11T08:52:15.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/bd/980187b627f9c5126ecbe909cc23f7d2c23ea4b046b2d3511d80ea0a8b79/yaspin-3.3.0-py3-none-any.whl", hash = "sha256:ab5113be4b34ef33f7d4d97be9b6867101ad020c2fb02bc92e3137c75b06d712", size = 21800, upload-time = "2025-10-11T08:52:13.932Z" }, +] + [[package]] name = "zipp" version = "3.23.0" diff --git a/examples/tutorials/10_agentic/10_temporal/020_state_machine/Dockerfile b/examples/tutorials/10_agentic/10_temporal/020_state_machine/Dockerfile index 4ec36c2b..002100c0 100644 --- a/examples/tutorials/10_agentic/10_temporal/020_state_machine/Dockerfile +++ b/examples/tutorials/10_agentic/10_temporal/020_state_machine/Dockerfile @@ -28,19 +28,24 @@ RUN uv pip install --system --upgrade pip setuptools wheel ENV UV_HTTP_TIMEOUT=1000 -# Copy just the requirements file to optimize caching -COPY 020_state_machine/requirements.txt /app/requirements.txt +# Copy pyproject.toml and README.md to install dependencies +COPY 020_state_machine/pyproject.toml /app/020_state_machine/pyproject.toml +COPY 020_state_machine/README.md /app/020_state_machine/README.md -WORKDIR /app/ - -# Install the required Python packages -RUN uv pip install --system -r requirements.txt +WORKDIR /app/020_state_machine # Copy the project code -COPY 020_state_machine/project /app/project +COPY 020_state_machine/project /app/020_state_machine/project + +# Install the required Python packages from pyproject.toml +RUN uv pip install --system . + +WORKDIR /app/020_state_machine + +ENV PYTHONPATH=/app # Run the ACP server using uvicorn CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] # When we deploy the worker, we will replace the CMD with the following -# CMD ["python", "-m", "run_worker"] \ No newline at end of file +# CMD ["python", "-m", "run_worker"] diff --git a/examples/tutorials/10_agentic/10_temporal/020_state_machine/manifest.yaml b/examples/tutorials/10_agentic/10_temporal/020_state_machine/manifest.yaml index 56adeba6..d7936096 100644 --- a/examples/tutorials/10_agentic/10_temporal/020_state_machine/manifest.yaml +++ b/examples/tutorials/10_agentic/10_temporal/020_state_machine/manifest.yaml @@ -22,17 +22,17 @@ build: # - Your agent's directory (your custom agent code) # These paths are collected and sent to the Docker daemon for building include_paths: - - 10_agentic/10_temporal/020_state_machine + - 020_state_machine # Path to your agent's Dockerfile # This defines how your agent's image is built from the context # Relative to the root directory - dockerfile: 10_agentic/10_temporal/020_state_machine/Dockerfile + dockerfile: 020_state_machine/Dockerfile # Path to your agent's .dockerignore # Filters unnecessary files from the build context # Helps keep build context small and builds fast - dockerignore: 10_agentic/10_temporal/020_state_machine/.dockerignore + dockerignore: 020_state_machine/.dockerignore # Local Development Configuration diff --git a/examples/tutorials/10_agentic/10_temporal/020_state_machine/tests/test_agent.py b/examples/tutorials/10_agentic/10_temporal/020_state_machine/tests/test_agent.py new file mode 100644 index 00000000..0c3da525 --- /dev/null +++ b/examples/tutorials/10_agentic/10_temporal/020_state_machine/tests/test_agent.py @@ -0,0 +1,190 @@ +""" +Sample tests for AgentEx Temporal State Machine agent. + +This test suite demonstrates how to test a state machine-based agent that: +- Uses state transitions (WAITING โ†’ CLARIFYING โ†’ PERFORMING_DEEP_RESEARCH) +- Asks follow-up questions before performing research +- Performs deep web research using MCP servers +- Handles multi-turn conversations with context preservation + +Key features tested: +1. State Machine Flow: Agent transitions through multiple states +2. Follow-up Questions: Agent clarifies queries before research +3. Deep Research: Agent performs extensive web research +4. Multi-turn Support: User can ask follow-ups about research + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Ensure OPENAI_API_KEY is set in the environment +4. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: at020-state-machine) +""" + +from agentex.types.tool_request_content import ToolRequestContent +from pure_eval.core import is_expression_interesting +import os +import uuid +import asyncio +import pytest +import pytest_asyncio +from agentex import AsyncAgentex +from agentex.types import TaskMessage, TextContent +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam +from test_utils.agentic import ( + send_event_and_poll_yielding, + stream_agent_response, + poll_messages, + stream_task_messages, +) + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "at020-state-machine") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling with state machine workflow.""" + @pytest.mark.asyncio + async def test_send_event_and_poll_simple_query(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending a simple event and polling for the response (no tool use).""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + # Wait for workflow to initialize + await asyncio.sleep(1) + + # Send a simple message that shouldn't require tool use + user_message = "Hello! Please tell me the latest news about AI and AI startups." + messages = [] + found_agent_message = False + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=user_message, + timeout=30, + sleep_interval=1.0, + ): + ## we should expect to get a question from the agent + if message.content.type == "text" and message.content.author == "agent": + found_agent_message = True + break + + assert found_agent_message, "Did not find an agent message" + + # now we want to clarity that message + await asyncio.sleep(2) + next_user_message = "I want to know what viral news came up and which startups failed, got acquired, or became very successful or popular in the last 3 months" + starting_deep_research_message = False + uses_tool_requests = False + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task.id, + user_message=next_user_message, + timeout=30, + sleep_interval=1.0, + ): + if message.content.type == "text" and message.content.author == "agent": + if "starting deep research" in message.content.content.lower(): + starting_deep_research_message = True + if isinstance(message.content, ToolRequestContent): + uses_tool_requests = True + break + + assert starting_deep_research_message, "Did not start deep research" + assert uses_tool_requests, "Did not use tool requests" + +class TestStreamingEvents: + """Test streaming event sending with state machine workflow.""" + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and streaming the response.""" + # Create a task for this conversation + task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + task = task_response.result + assert task is not None + + found_agent_message = False + async def poll_message_in_background() -> None: + nonlocal found_agent_message + async for message in stream_task_messages( + client=client, + task_id=task.id, + timeout=30, + ): + if message.content.type == "text" and message.content.author == "agent": + found_agent_message = True + break + + assert found_agent_message, "Did not find an agent message" + + poll_task = asyncio.create_task(poll_message_in_background()) + # create the first + user_message = "Hello! Please tell me the latest news about AI and AI startups." + await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": TextContentParam(type="text", author="user", content=user_message)}) + + await poll_task + + await asyncio.sleep(2) + starting_deep_research_message = False + uses_tool_requests = False + async def poll_message_in_background_2() -> None: + nonlocal starting_deep_research_message, uses_tool_requests + async for message in stream_task_messages( + client=client, + task_id=task.id, + timeout=30, + ): + # can you add the same checks as we did in the non-streaming events test? + if message.content.type == "text" and message.content.author == "agent": + if "starting deep research" in message.content.content.lower(): + starting_deep_research_message = True + if isinstance(message.content, ToolRequestContent): + uses_tool_requests = True + break + + assert starting_deep_research_message, "Did not start deep research" + assert uses_tool_requests, "Did not use tool requests" + + poll_task_2 = asyncio.create_task(poll_message_in_background_2()) + + next_user_message = "I want to know what viral news came up and which startups failed, got acquired, or became very successful or popular in the last 3 months" + await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": TextContentParam(type="text", author="user", content=next_user_message)}) + await poll_task_2 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_agentic/10_temporal/030_custom_activities/Dockerfile b/examples/tutorials/10_agentic/10_temporal/030_custom_activities/Dockerfile index b765afa5..e631450f 100644 --- a/examples/tutorials/10_agentic/10_temporal/030_custom_activities/Dockerfile +++ b/examples/tutorials/10_agentic/10_temporal/030_custom_activities/Dockerfile @@ -28,17 +28,18 @@ RUN uv pip install --system --upgrade pip setuptools wheel ENV UV_HTTP_TIMEOUT=1000 -# Copy just the pyproject.toml file to optimize caching +# Copy pyproject.toml and README.md to install dependencies COPY 030_custom_activities/pyproject.toml /app/030_custom_activities/pyproject.toml +COPY 030_custom_activities/README.md /app/030_custom_activities/README.md WORKDIR /app/030_custom_activities -# Install the required Python packages using uv -RUN uv pip install --system . - # Copy the project code COPY 030_custom_activities/project /app/030_custom_activities/project +# Install the required Python packages from pyproject.toml +RUN uv pip install --system . + # Run the ACP server using uvicorn CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_agentic/10_temporal/030_custom_activities/tests/test_agent.py b/examples/tutorials/10_agentic/10_temporal/030_custom_activities/tests/test_agent.py new file mode 100644 index 00000000..aae663dd --- /dev/null +++ b/examples/tutorials/10_agentic/10_temporal/030_custom_activities/tests/test_agent.py @@ -0,0 +1,147 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: at030-custom-activities) +""" + +import os +import uuid +import asyncio +import pytest +import pytest_asyncio +from agentex import AsyncAgentex +from agentex.types import TaskMessage +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam +from test_utils.agentic import ( + poll_for_agent_response, + send_event_and_poll_yielding, + stream_agent_response, + validate_text_in_response, + poll_messages, +) + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "at030-custom-activities") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and polling for the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # TODO: Poll for the initial task creation message (if your agent sends one) + # async for message in poll_messages( + # client=client, + # task_id=task.id, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected initial message + # assert "expected initial text" in message.content.content + # break + + # TODO: Send an event and poll for response using the yielding helper function + # user_message = "Your test message here" + # async for message in send_event_and_poll_yielding( + # client=client, + # agent_id=agent_id, + # task_id=task.id, + # user_message=user_message, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected response + # assert "expected response text" in message.content.content + # break + pass + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and streaming the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # user_message = "Your test message here" + + # # Collect events from stream + # all_events = [] + + # async def collect_stream_events(): + # async for event in stream_agent_response( + # client=client, + # task_id=task.id, + # timeout=30, + # ): + # all_events.append(event) + + # # Start streaming task + # stream_task = asyncio.create_task(collect_stream_events()) + + # # Send the event + # event_content = TextContentParam(type="text", author="user", content=user_message) + # await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + + # # Wait for streaming to complete + # await stream_task + + # # TODO: Add your validation here + # assert len(all_events) > 0, "No events received in streaming response" + pass + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/examples/tutorials/10_agentic/10_temporal/050_agent_chat_guardrails/Dockerfile b/examples/tutorials/10_agentic/10_temporal/050_agent_chat_guardrails/Dockerfile index eda1cf62..5f3f35c4 100644 --- a/examples/tutorials/10_agentic/10_temporal/050_agent_chat_guardrails/Dockerfile +++ b/examples/tutorials/10_agentic/10_temporal/050_agent_chat_guardrails/Dockerfile @@ -28,16 +28,17 @@ RUN uv pip install --system --upgrade pip setuptools wheel ENV UV_HTTP_TIMEOUT=1000 -# Copy just the requirements file to optimize caching -COPY 010_agent_chat/requirements.txt /app/requirements.txt +# Copy pyproject.toml and README.md to install dependencies +COPY 050_agent_chat_guardrails/pyproject.toml /app/050_agent_chat_guardrails/pyproject.toml +COPY 050_agent_chat_guardrails/README.md /app/050_agent_chat_guardrails/README.md -WORKDIR /app/ - -# Install the required Python packages -RUN uv pip install --system -r requirements.txt +WORKDIR /app/050_agent_chat_guardrails # Copy the project code -COPY 010_agent_chat/project /app/project +COPY 050_agent_chat_guardrails/project /app/050_agent_chat_guardrails/project + +# Install the required Python packages from pyproject.toml +RUN uv pip install --system . # Run the ACP server using uvicorn CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_agentic/10_temporal/050_agent_chat_guardrails/tests/test_agent.py b/examples/tutorials/10_agentic/10_temporal/050_agent_chat_guardrails/tests/test_agent.py new file mode 100644 index 00000000..fe44b317 --- /dev/null +++ b/examples/tutorials/10_agentic/10_temporal/050_agent_chat_guardrails/tests/test_agent.py @@ -0,0 +1,147 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: at050-agent-chat-guardrails) +""" + +import os +import uuid +import asyncio +import pytest +import pytest_asyncio +from agentex import AsyncAgentex +from agentex.types import TaskMessage +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam +from test_utils.agentic import ( + poll_for_agent_response, + send_event_and_poll_yielding, + stream_agent_response, + validate_text_in_response, + poll_messages, +) + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "at050-agent-chat-guardrails") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and polling for the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # TODO: Poll for the initial task creation message (if your agent sends one) + # async for message in poll_messages( + # client=client, + # task_id=task.id, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected initial message + # assert "expected initial text" in message.content.content + # break + + # TODO: Send an event and poll for response using the yielding helper function + # user_message = "Your test message here" + # async for message in send_event_and_poll_yielding( + # client=client, + # agent_id=agent_id, + # task_id=task.id, + # user_message=user_message, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected response + # assert "expected response text" in message.content.content + # break + pass + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and streaming the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # user_message = "Your test message here" + + # # Collect events from stream + # all_events = [] + + # async def collect_stream_events(): + # async for event in stream_agent_response( + # client=client, + # task_id=task.id, + # timeout=30, + # ): + # all_events.append(event) + + # # Start streaming task + # stream_task = asyncio.create_task(collect_stream_events()) + + # # Send the event + # event_content = TextContentParam(type="text", author="user", content=user_message) + # await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + + # # Wait for streaming to complete + # await stream_task + + # # TODO: Add your validation here + # assert len(all_events) > 0, "No events received in streaming response" + pass + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/examples/tutorials/10_agentic/10_temporal/060_open_ai_agents_sdk_hello_world/.dockerignore b/examples/tutorials/10_agentic/10_temporal/060_open_ai_agents_sdk_hello_world/.dockerignore new file mode 100644 index 00000000..c4948947 --- /dev/null +++ b/examples/tutorials/10_agentic/10_temporal/060_open_ai_agents_sdk_hello_world/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_agentic/10_temporal/060_open_ai_agents_sdk_hello_world/Dockerfile b/examples/tutorials/10_agentic/10_temporal/060_open_ai_agents_sdk_hello_world/Dockerfile index 531cf804..f5a592eb 100644 --- a/examples/tutorials/10_agentic/10_temporal/060_open_ai_agents_sdk_hello_world/Dockerfile +++ b/examples/tutorials/10_agentic/10_temporal/060_open_ai_agents_sdk_hello_world/Dockerfile @@ -30,19 +30,24 @@ RUN uv pip install --system --upgrade pip setuptools wheel ENV UV_HTTP_TIMEOUT=1000 -# Copy just the pyproject.toml file to optimize caching -COPY example_tutorial/pyproject.toml /app/example_tutorial/pyproject.toml +# Copy pyproject.toml and README.md to install dependencies +COPY 060_open_ai_agents_sdk_hello_world/pyproject.toml /app/060_open_ai_agents_sdk_hello_world/pyproject.toml +COPY 060_open_ai_agents_sdk_hello_world/README.md /app/060_open_ai_agents_sdk_hello_world/README.md -WORKDIR /app/example_tutorial +WORKDIR /app/060_open_ai_agents_sdk_hello_world -# Install the required Python packages using uv +# Copy the project code +COPY 060_open_ai_agents_sdk_hello_world/project /app/060_open_ai_agents_sdk_hello_world/project + +# Install the required Python packages from pyproject.toml RUN uv pip install --system . -# Copy the project code -COPY example_tutorial/project /app/example_tutorial/project +WORKDIR /app/060_open_ai_agents_sdk_hello_world + +ENV PYTHONPATH=/app # Run the ACP server using uvicorn CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] # When we deploy the worker, we will replace the CMD with the following -# CMD ["python", "-m", "run_worker"] \ No newline at end of file +# CMD ["python", "-m", "run_worker"] diff --git a/examples/tutorials/10_agentic/10_temporal/060_open_ai_agents_sdk_hello_world/manifest.yaml b/examples/tutorials/10_agentic/10_temporal/060_open_ai_agents_sdk_hello_world/manifest.yaml index a165dca9..0299ac53 100644 --- a/examples/tutorials/10_agentic/10_temporal/060_open_ai_agents_sdk_hello_world/manifest.yaml +++ b/examples/tutorials/10_agentic/10_temporal/060_open_ai_agents_sdk_hello_world/manifest.yaml @@ -22,17 +22,17 @@ build: # - Your agent's directory (your custom agent code) # These paths are collected and sent to the Docker daemon for building include_paths: - - example_tutorial + - 060_open_ai_agents_sdk_hello_world # Path to your agent's Dockerfile # This defines how your agent's image is built from the context # Relative to the root directory - dockerfile: example_tutorial/Dockerfile + dockerfile: 060_open_ai_agents_sdk_hello_world/Dockerfile # Path to your agent's .dockerignore # Filters unnecessary files from the build context # Helps keep build context small and builds fast - dockerignore: example_tutorial/.dockerignore + dockerignore: 060_open_ai_agents_sdk_hello_world/.dockerignore # Local Development Configuration diff --git a/examples/tutorials/10_agentic/10_temporal/060_open_ai_agents_sdk_hello_world/tests/test_agent.py b/examples/tutorials/10_agentic/10_temporal/060_open_ai_agents_sdk_hello_world/tests/test_agent.py new file mode 100644 index 00000000..ed51900d --- /dev/null +++ b/examples/tutorials/10_agentic/10_temporal/060_open_ai_agents_sdk_hello_world/tests/test_agent.py @@ -0,0 +1,147 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: example-tutorial) +""" + +import os +import uuid +import asyncio +import pytest +import pytest_asyncio +from agentex import AsyncAgentex +from agentex.types import TaskMessage +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam +from test_utils.agentic import ( + poll_for_agent_response, + send_event_and_poll_yielding, + stream_agent_response, + validate_text_in_response, + poll_messages, +) + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "example-tutorial") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and polling for the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # TODO: Poll for the initial task creation message (if your agent sends one) + # async for message in poll_messages( + # client=client, + # task_id=task.id, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected initial message + # assert "expected initial text" in message.content.content + # break + + # TODO: Send an event and poll for response using the yielding helper function + # user_message = "Your test message here" + # async for message in send_event_and_poll_yielding( + # client=client, + # agent_id=agent_id, + # task_id=task.id, + # user_message=user_message, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected response + # assert "expected response text" in message.content.content + # break + pass + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and streaming the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # user_message = "Your test message here" + + # # Collect events from stream + # all_events = [] + + # async def collect_stream_events(): + # async for event in stream_agent_response( + # client=client, + # task_id=task.id, + # timeout=30, + # ): + # all_events.append(event) + + # # Start streaming task + # stream_task = asyncio.create_task(collect_stream_events()) + + # # Send the event + # event_content = TextContentParam(type="text", author="user", content=user_message) + # await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + + # # Wait for streaming to complete + # await stream_task + + # # TODO: Add your validation here + # assert len(all_events) > 0, "No events received in streaming response" + pass + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/examples/tutorials/10_agentic/10_temporal/070_open_ai_agents_sdk_tools/.dockerignore b/examples/tutorials/10_agentic/10_temporal/070_open_ai_agents_sdk_tools/.dockerignore new file mode 100644 index 00000000..c4948947 --- /dev/null +++ b/examples/tutorials/10_agentic/10_temporal/070_open_ai_agents_sdk_tools/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_agentic/10_temporal/070_open_ai_agents_sdk_tools/Dockerfile b/examples/tutorials/10_agentic/10_temporal/070_open_ai_agents_sdk_tools/Dockerfile index 531cf804..68650d6f 100644 --- a/examples/tutorials/10_agentic/10_temporal/070_open_ai_agents_sdk_tools/Dockerfile +++ b/examples/tutorials/10_agentic/10_temporal/070_open_ai_agents_sdk_tools/Dockerfile @@ -30,19 +30,22 @@ RUN uv pip install --system --upgrade pip setuptools wheel ENV UV_HTTP_TIMEOUT=1000 -# Copy just the pyproject.toml file to optimize caching -COPY example_tutorial/pyproject.toml /app/example_tutorial/pyproject.toml +# Copy pyproject.toml and README.md to install dependencies +COPY 070_open_ai_agents_sdk_tools/pyproject.toml /app/070_open_ai_agents_sdk_tools/pyproject.toml +COPY 070_open_ai_agents_sdk_tools/README.md /app/070_open_ai_agents_sdk_tools/README.md -WORKDIR /app/example_tutorial +WORKDIR /app/070_open_ai_agents_sdk_tools -# Install the required Python packages using uv +# Copy the project code +COPY 070_open_ai_agents_sdk_tools/project /app/070_open_ai_agents_sdk_tools/project + +# Install the required Python packages from pyproject.toml RUN uv pip install --system . -# Copy the project code -COPY example_tutorial/project /app/example_tutorial/project +WORKDIR /app/070_open_ai_agents_sdk_tools # Run the ACP server using uvicorn CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] # When we deploy the worker, we will replace the CMD with the following -# CMD ["python", "-m", "run_worker"] \ No newline at end of file +# CMD ["python", "-m", "run_worker"] diff --git a/examples/tutorials/10_agentic/10_temporal/070_open_ai_agents_sdk_tools/manifest.yaml b/examples/tutorials/10_agentic/10_temporal/070_open_ai_agents_sdk_tools/manifest.yaml index a165dca9..8df49390 100644 --- a/examples/tutorials/10_agentic/10_temporal/070_open_ai_agents_sdk_tools/manifest.yaml +++ b/examples/tutorials/10_agentic/10_temporal/070_open_ai_agents_sdk_tools/manifest.yaml @@ -15,33 +15,32 @@ build: context: # Root directory for the build context - root: ../ # Keep this as the default root + root: ../ # Keep this as the default root # Paths to include in the Docker build context # Must include: # - Your agent's directory (your custom agent code) # These paths are collected and sent to the Docker daemon for building include_paths: - - example_tutorial + - 070_open_ai_agents_sdk_tools # Path to your agent's Dockerfile # This defines how your agent's image is built from the context # Relative to the root directory - dockerfile: example_tutorial/Dockerfile + dockerfile: 070_open_ai_agents_sdk_tools/Dockerfile # Path to your agent's .dockerignore # Filters unnecessary files from the build context # Helps keep build context small and builds fast - dockerignore: example_tutorial/.dockerignore - + dockerignore: 070_open_ai_agents_sdk_tools/.dockerignore # Local Development Configuration # ----------------------------- # Only used when running the agent locally local_development: agent: - port: 8000 # Port where your local ACP server is running - host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) # File paths for local development (relative to this manifest.yaml) paths: @@ -52,7 +51,7 @@ local_development: # ../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) @@ -60,7 +59,6 @@ local_development: # ../shared/worker.py (shared across projects) worker: project/run_worker.py - # Agent Configuration # ----------------- agent: @@ -99,15 +97,14 @@ agent: # - env_var_name: OPENAI_API_KEY # secret_name: openai-api-key # secret_key: api-key - - # Optional: Set Environment variables for running your agent locally as well + + # Optional: Set Environment variables for running your agent locally as well # as for deployment later on env: OPENAI_API_KEY: "" # OPENAI_BASE_URL: "" OPENAI_ORG_ID: "" - # Deployment Configuration # ----------------------- # Configuration for deploying your agent to Kubernetes clusters @@ -115,10 +112,10 @@ deployment: # Container image configuration image: repository: "" # Update with your container registry - tag: "latest" # Default tag, should be versioned in production + tag: "latest" # Default tag, should be versioned in production imagePullSecrets: - - name: my-registry-secret # Update with your image pull secret name + - name: my-registry-secret # Update with your image pull secret name # Global deployment settings that apply to all clusters # These can be overridden using --override-file with custom configuration files @@ -126,10 +123,10 @@ deployment: agent: name: "example-tutorial" description: "An AgentEx agent" - + # Default replica count replicaCount: 1 - + # Default resource requirements resources: requests: @@ -137,4 +134,5 @@ deployment: memory: "1Gi" limits: cpu: "1000m" - memory: "2Gi" \ No newline at end of file + memory: "2Gi" + diff --git a/examples/tutorials/10_agentic/10_temporal/070_open_ai_agents_sdk_tools/tests/test_agent.py b/examples/tutorials/10_agentic/10_temporal/070_open_ai_agents_sdk_tools/tests/test_agent.py new file mode 100644 index 00000000..ed51900d --- /dev/null +++ b/examples/tutorials/10_agentic/10_temporal/070_open_ai_agents_sdk_tools/tests/test_agent.py @@ -0,0 +1,147 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: example-tutorial) +""" + +import os +import uuid +import asyncio +import pytest +import pytest_asyncio +from agentex import AsyncAgentex +from agentex.types import TaskMessage +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam +from test_utils.agentic import ( + poll_for_agent_response, + send_event_and_poll_yielding, + stream_agent_response, + validate_text_in_response, + poll_messages, +) + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "example-tutorial") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and polling for the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # TODO: Poll for the initial task creation message (if your agent sends one) + # async for message in poll_messages( + # client=client, + # task_id=task.id, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected initial message + # assert "expected initial text" in message.content.content + # break + + # TODO: Send an event and poll for response using the yielding helper function + # user_message = "Your test message here" + # async for message in send_event_and_poll_yielding( + # client=client, + # agent_id=agent_id, + # task_id=task.id, + # user_message=user_message, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected response + # assert "expected response text" in message.content.content + # break + pass + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and streaming the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # user_message = "Your test message here" + + # # Collect events from stream + # all_events = [] + + # async def collect_stream_events(): + # async for event in stream_agent_response( + # client=client, + # task_id=task.id, + # timeout=30, + # ): + # all_events.append(event) + + # # Start streaming task + # stream_task = asyncio.create_task(collect_stream_events()) + + # # Send the event + # event_content = TextContentParam(type="text", author="user", content=user_message) + # await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + + # # Wait for streaming to complete + # await stream_task + + # # TODO: Add your validation here + # assert len(all_events) > 0, "No events received in streaming response" + pass + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/examples/tutorials/10_agentic/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/.dockerignore b/examples/tutorials/10_agentic/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/.dockerignore new file mode 100644 index 00000000..c4948947 --- /dev/null +++ b/examples/tutorials/10_agentic/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_agentic/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/Dockerfile b/examples/tutorials/10_agentic/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/Dockerfile index 531cf804..4c21bd87 100644 --- a/examples/tutorials/10_agentic/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/Dockerfile +++ b/examples/tutorials/10_agentic/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/Dockerfile @@ -30,19 +30,23 @@ RUN uv pip install --system --upgrade pip setuptools wheel ENV UV_HTTP_TIMEOUT=1000 -# Copy just the pyproject.toml file to optimize caching -COPY example_tutorial/pyproject.toml /app/example_tutorial/pyproject.toml +# Copy pyproject.toml and README.md to install dependencies +COPY 080_open_ai_agents_sdk_human_in_the_loop/pyproject.toml /app/080_open_ai_agents_sdk_human_in_the_loop/pyproject.toml +COPY 080_open_ai_agents_sdk_human_in_the_loop/README.md /app/080_open_ai_agents_sdk_human_in_the_loop/README.md -WORKDIR /app/example_tutorial +WORKDIR /app/080_open_ai_agents_sdk_human_in_the_loop -# Install the required Python packages using uv +# Copy the project code +COPY 080_open_ai_agents_sdk_human_in_the_loop/project /app/080_open_ai_agents_sdk_human_in_the_loop/project + +# Install the required Python packages from pyproject.toml RUN uv pip install --system . -# Copy the project code -COPY example_tutorial/project /app/example_tutorial/project +WORKDIR /app/080_open_ai_agents_sdk_human_in_the_loop +ENV PYTHONPATH=/app # Run the ACP server using uvicorn CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] # When we deploy the worker, we will replace the CMD with the following -# CMD ["python", "-m", "run_worker"] \ No newline at end of file +# CMD ["python", "-m", "run_worker"] diff --git a/examples/tutorials/10_agentic/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/manifest.yaml b/examples/tutorials/10_agentic/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/manifest.yaml index a165dca9..4ff4e6cc 100644 --- a/examples/tutorials/10_agentic/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/manifest.yaml +++ b/examples/tutorials/10_agentic/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/manifest.yaml @@ -22,17 +22,17 @@ build: # - Your agent's directory (your custom agent code) # These paths are collected and sent to the Docker daemon for building include_paths: - - example_tutorial + - 080_open_ai_agents_sdk_human_in_the_loop # Path to your agent's Dockerfile # This defines how your agent's image is built from the context # Relative to the root directory - dockerfile: example_tutorial/Dockerfile + dockerfile: 080_open_ai_agents_sdk_human_in_the_loop/Dockerfile # Path to your agent's .dockerignore # Filters unnecessary files from the build context # Helps keep build context small and builds fast - dockerignore: example_tutorial/.dockerignore + dockerignore: 080_open_ai_agents_sdk_human_in_the_loop/.dockerignore # Local Development Configuration diff --git a/examples/tutorials/10_agentic/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/tests/test_agent.py b/examples/tutorials/10_agentic/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/tests/test_agent.py new file mode 100644 index 00000000..ed51900d --- /dev/null +++ b/examples/tutorials/10_agentic/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/tests/test_agent.py @@ -0,0 +1,147 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: example-tutorial) +""" + +import os +import uuid +import asyncio +import pytest +import pytest_asyncio +from agentex import AsyncAgentex +from agentex.types import TaskMessage +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam +from test_utils.agentic import ( + poll_for_agent_response, + send_event_and_poll_yielding, + stream_agent_response, + validate_text_in_response, + poll_messages, +) + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "example-tutorial") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and polling for the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # TODO: Poll for the initial task creation message (if your agent sends one) + # async for message in poll_messages( + # client=client, + # task_id=task.id, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected initial message + # assert "expected initial text" in message.content.content + # break + + # TODO: Send an event and poll for response using the yielding helper function + # user_message = "Your test message here" + # async for message in send_event_and_poll_yielding( + # client=client, + # agent_id=agent_id, + # task_id=task.id, + # user_message=user_message, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected response + # assert "expected response text" in message.content.content + # break + pass + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and streaming the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # user_message = "Your test message here" + + # # Collect events from stream + # all_events = [] + + # async def collect_stream_events(): + # async for event in stream_agent_response( + # client=client, + # task_id=task.id, + # timeout=30, + # ): + # all_events.append(event) + + # # Start streaming task + # stream_task = asyncio.create_task(collect_stream_events()) + + # # Send the event + # event_content = TextContentParam(type="text", author="user", content=user_message) + # await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + + # # Wait for streaming to complete + # await stream_task + + # # TODO: Add your validation here + # assert len(all_events) > 0, "No events received in streaming response" + pass + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/examples/tutorials/TEST_RUNNER_README.md b/examples/tutorials/TEST_RUNNER_README.md new file mode 100644 index 00000000..557bd540 --- /dev/null +++ b/examples/tutorials/TEST_RUNNER_README.md @@ -0,0 +1,142 @@ +# Tutorial Test Runner + +This directory contains a test runner script that automates the process of starting an agent and running its tests. + +## Prerequisites + +- Python 3.12+ +- `uv` installed and available in PATH +- `httpx` Python package (for health checks) + +## Usage + +From the `tutorials/` directory, run: + +```bash +python run_tutorial_test.py +``` + +### Examples + +```bash +# Test a sync tutorial +python run_tutorial_test.py 00_sync/000_hello_acp + +# Test an agentic tutorial +python run_tutorial_test.py 10_agentic/00_base/000_hello_acp +python run_tutorial_test.py 10_agentic/00_base/010_multiturn +python run_tutorial_test.py 10_agentic/00_base/020_streaming + +# Test with custom base URL +python run_tutorial_test.py 10_agentic/00_base/000_hello_acp --base-url http://localhost:5003 +``` + +## What the Script Does + +1. **Validates Paths**: Checks that the tutorial directory, manifest.yaml, and tests directory exist +2. **Starts Agent**: Runs `uv run agentex agents run --manifest manifest.yaml` in the tutorial directory +3. **Health Check**: Polls the agent's health endpoint (default: http://localhost:5003/health) until it's live +4. **Runs Tests**: Executes `uv run pytest tests/ -v --tb=short` in the tutorial directory +5. **Cleanup**: Gracefully stops the agent process (or kills it if necessary) + +## Options + +``` +positional arguments: + tutorial_dir Path to the tutorial directory (relative to current directory) + +optional arguments: + -h, --help Show help message and exit + --base-url BASE_URL Base URL for the AgentEx server (default: http://localhost:5003) +``` + +## Exit Codes + +- `0`: All tests passed successfully +- `1`: Tests failed or error occurred +- `130`: Interrupted by user (Ctrl+C) + +## Example Output + +``` +================================================================================ +AgentEx Tutorial Test Runner +================================================================================ + +๐Ÿš€ Starting agent from: 10_agentic/00_base/000_hello_acp +๐Ÿ“„ Manifest: 10_agentic/00_base/000_hello_acp/manifest.yaml +๐Ÿ’ป Running command: uv run agentex agents run --manifest manifest.yaml +๐Ÿ“ Working directory: 10_agentic/00_base/000_hello_acp +โœ… Agent process started (PID: 12345) + +๐Ÿ” Checking agent health at http://localhost:5003/health... +โณ Waiting for agent... (attempt 1/30) +โณ Waiting for agent... (attempt 2/30) +โœ… Agent is live! (attempt 3/30) + +โณ Waiting 2 seconds for agent to fully initialize... + +๐Ÿงช Running tests from: 10_agentic/00_base/000_hello_acp/tests +๐Ÿ’ป Running command: uv run pytest tests/ -v --tb=short +๐Ÿ“ Working directory: 10_agentic/00_base/000_hello_acp + +============================= test session starts ============================== +... +============================= X passed in Y.YYs ================================ + +โœ… All tests passed! + +๐Ÿ›‘ Stopping agent (PID: 12345)... +โœ… Agent stopped gracefully + +================================================================================ +โœ… Test run completed successfully! +================================================================================ +``` + +## Troubleshooting + +### Agent doesn't become live + +If the health check times out: +- Check that port 5003 is not already in use +- Look at the agent logs to see if there are startup errors +- Try increasing the timeout by modifying the `max_attempts` parameter in the script + +### Tests fail + +- Ensure the agent is properly configured in manifest.yaml +- Check that all dependencies are installed in the tutorial's virtual environment +- Review test output for specific failure reasons + +### "uv: command not found" + +Install `uv`: +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +### Missing httpx package + +The script requires `httpx` for health checks. It should be installed automatically via the tutorial's dependencies, but if needed: +```bash +pip install httpx +``` + +## Integration with CI/CD + +This script is designed to be CI/CD friendly: + +```bash +# Run all agentic tutorials +for tutorial in 10_agentic/00_base/*/; do + python run_tutorial_test.py "$tutorial" || exit 1 +done +``` + +## Notes + +- The script automatically sets `AGENTEX_API_BASE_URL` environment variable when running tests +- Agent processes are always cleaned up, even if tests fail or the script is interrupted +- The script uses line-buffered output for real-time feedback +- Health checks poll every 1 second for up to 30 seconds (configurable in the code) diff --git a/examples/tutorials/pytest.ini b/examples/tutorials/pytest.ini new file mode 100644 index 00000000..7be1a076 --- /dev/null +++ b/examples/tutorials/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +pythonpath = . +testpaths = . +addopts = --import-mode=importlib diff --git a/examples/tutorials/run_all_agentic_tests.sh b/examples/tutorials/run_all_agentic_tests.sh new file mode 100755 index 00000000..f2454263 --- /dev/null +++ b/examples/tutorials/run_all_agentic_tests.sh @@ -0,0 +1,385 @@ +#!/bin/bash +# +# Run all agentic tutorial tests +# +# This script runs the test runner for all agentic tutorials in sequence. +# It stops at the first failure unless --continue-on-error is specified. +# +# Usage: +# ./run_all_agentic_tests.sh # Run all tutorials +# ./run_all_agentic_tests.sh --continue-on-error # Run all, continue on error +# ./run_all_agentic_tests.sh # Run single tutorial +# ./run_all_agentic_tests.sh --view-logs # View most recent agent logs +# ./run_all_agentic_tests.sh --view-logs # View logs for specific tutorial +# + +set -e # Exit on error + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +AGENT_PORT=8000 +AGENTEX_SERVER_PORT=5003 + +# Parse arguments +CONTINUE_ON_ERROR=false +SINGLE_TUTORIAL="" +VIEW_LOGS=false + +for arg in "$@"; do + if [[ "$arg" == "--continue-on-error" ]]; then + CONTINUE_ON_ERROR=true + elif [[ "$arg" == "--view-logs" ]]; then + VIEW_LOGS=true + else + SINGLE_TUTORIAL="$arg" + fi +done + +# Find all agentic tutorial directories +ALL_TUTORIALS=( + # sync tutorials + "00_sync/000_hello_acp" + "00_sync/010_multiturn" + "00_sync/020_streaming" + # base tutorials + "10_agentic/00_base/000_hello_acp" + "10_agentic/00_base/010_multiturn" + "10_agentic/00_base/020_streaming" + "10_agentic/00_base/030_tracing" + "10_agentic/00_base/040_other_sdks" + "10_agentic/00_base/080_batch_events" +# "10_agentic/00_base/090_multi_agent_non_temporal" This will require its own version of this + # temporal tutorials + "10_agentic/10_temporal/000_hello_acp" + "10_agentic/10_temporal/010_agent_chat" + "10_agentic/10_temporal/020_state_machine" +) + +PASSED=0 +FAILED=0 +FAILED_TESTS=() + +# Function to check prerequisites for running this test suite +check_prerequisites() { + # Check that we are in the examples/tutorials directory + if [[ "$PWD" != */examples/tutorials ]]; then + echo -e "${RED}โŒ Please run this script from the examples/tutorials directory${NC}" + exit 1 + fi + + # Check if uv is available + if ! command -v uv &> /dev/null; then + echo -e "${RED}โŒ uv is required but not installed${NC}" + echo "Please install uv: curl -LsSf https://astral.sh/uv/install.sh | sh" + exit 1 + fi + + echo -e "${GREEN}โœ… Prerequisites check passed${NC}" +} + +# Function to wait for agent to be ready +wait_for_agent_ready() { + local name=$1 + local logfile="/tmp/agentex-${name}.log" + local timeout=30 # seconds + local elapsed=0 + + echo -e "${YELLOW}โณ Waiting for ${name} agent to be ready...${NC}" + + while [ $elapsed -lt $timeout ]; do + if grep -q "Application startup complete" "$logfile" 2>/dev/null; then + echo -e "${GREEN}โœ… ${name} agent is ready${NC}" + return 0 + fi + sleep 1 + ((elapsed++)) + done + + echo -e "${RED}โŒ Timeout waiting for ${name} agent to be ready${NC}" + echo "Check logs: tail -f $logfile" + return 1 +} + +# Function to start agent in background +start_agent() { + local tutorial_path=$1 + local name=$(basename "$tutorial_path") + local logfile="/tmp/agentex-${name}.log" + + echo -e "${YELLOW}๐Ÿš€ Starting ${name} agent...${NC}" + + # Check if tutorial directory exists + if [[ ! -d "$tutorial_path" ]]; then + echo -e "${RED}โŒ Tutorial directory not found: $tutorial_path${NC}" + return 1 + fi + + # Check if manifest exists + if [[ ! -f "$tutorial_path/manifest.yaml" ]]; then + echo -e "${RED}โŒ Manifest not found: $tutorial_path/manifest.yaml${NC}" + return 1 + fi + + # Save current directory + local original_dir="$PWD" + + # Change to tutorial directory + cd "$tutorial_path" || return 1 + + # Start the agent in background and capture PID + uv run agentex agents run --manifest manifest.yaml > "$logfile" 2>&1 & + local pid=$! + + # Return to original directory + cd "$original_dir" + + echo "$pid" > "/tmp/agentex-${name}.pid" + echo -e "${GREEN}โœ… ${name} agent started (PID: $pid, logs: $logfile)${NC}" + + # Wait for agent to be ready + if ! wait_for_agent_ready "$name"; then + kill -9 $pid 2>/dev/null + return 1 + fi + + return 0 +} + +# Helper function to view agent container logs +view_agent_logs() { + local tutorial_path=$1 + + # If tutorial path is provided, view logs for that specific tutorial + if [[ -n "$tutorial_path" ]]; then + local name=$(basename "$tutorial_path") + local logfile="/tmp/agentex-${name}.log" + + echo -e "${YELLOW}๐Ÿ“‹ Viewing logs for ${name}...${NC}" + echo -e "${YELLOW}Log file: $logfile${NC}" + echo "" + + if [[ ! -f "$logfile" ]]; then + echo -e "${RED}โŒ Log file not found: $logfile${NC}" + return 1 + fi + + # Display the logs + tail -f "$logfile" + else + # No specific tutorial, find the most recent log file + local latest_log=$(ls -t /tmp/agentex-*.log 2>/dev/null | head -1) + + if [[ -z "$latest_log" ]]; then + echo -e "${RED}โŒ No agent log files found in /tmp/agentex-*.log${NC}" + echo -e "${YELLOW}Available log files:${NC}" + ls -lht /tmp/agentex-*.log 2>/dev/null || echo " (none)" + return 1 + fi + + echo -e "${YELLOW}๐Ÿ“‹ Viewing most recent agent logs...${NC}" + echo -e "${YELLOW}Log file: $latest_log${NC}" + echo "" + + # Display the logs + tail -f "$latest_log" + fi +} + +# Function to stop agent +stop_agent() { + local tutorial_path=$1 + local name=$(basename "$tutorial_path") + local pidfile="/tmp/agentex-${name}.pid" + local logfile="/tmp/agentex-${name}.log" + + echo -e "${YELLOW}๐Ÿ›‘ Stopping ${name} agent...${NC}" + + # Check if PID file exists + if [[ ! -f "$pidfile" ]]; then + echo -e "${YELLOW}โš ๏ธ No PID file found for ${name} agent${NC}" + return 0 + fi + + # Read PID from file + local pid=$(cat "$pidfile") + + # Check if process is running and kill it + if kill -0 "$pid" 2>/dev/null; then + echo -e "${YELLOW}Stopping ${name} agent (PID: $pid)${NC}" + kill "$pid" 2>/dev/null || true + rm -f "$pidfile" + else + echo -e "${YELLOW}โš ๏ธ ${name} agent was not running${NC}" + rm -f "$pidfile" + fi + + echo -e "${GREEN}โœ… ${name} agent stopped${NC}" + echo -e "${YELLOW}Logs available at: $logfile${NC}" + + return 0 +} + + +# Function to run test for a tutorial +run_test() { + local tutorial_path=$1 + local name=$(basename "$tutorial_path") + + echo -e "${YELLOW}๐Ÿงช Running tests for ${name}...${NC}" + + # Check if tutorial directory exists + if [[ ! -d "$tutorial_path" ]]; then + echo -e "${RED}โŒ Tutorial directory not found: $tutorial_path${NC}" + return 1 + fi + + # Check if test file exists + if [[ ! -f "$tutorial_path/tests/test_agent.py" ]]; then + echo -e "${RED}โŒ Test file not found: $tutorial_path/tests/test_agent.py${NC}" + return 1 + fi + + # Save current directory + local original_dir="$PWD" + + # Change to tutorial directory + cd "$tutorial_path" || return 1 + + # Run the tests + uv run pytest tests/test_agent.py -v -s + local exit_code=$? + + # Return to original directory + cd "$original_dir" + + if [ $exit_code -eq 0 ]; then + echo -e "${GREEN}โœ… Tests passed for ${name}${NC}" + return 0 + else + echo -e "${RED}โŒ Tests failed for ${name}${NC}" + return 1 + fi +} + +# Function to execute test flow for a single tutorial +execute_tutorial_test() { + local tutorial=$1 + + echo "" + echo "--------------------------------------------------------------------------------" + echo "Testing: $tutorial" + echo "--------------------------------------------------------------------------------" + + # Start the agent + if ! start_agent "$tutorial"; then + echo -e "${RED}โŒ FAILED to start agent: $tutorial${NC}" + ((FAILED++)) + FAILED_TESTS+=("$tutorial") + return 1 + fi + + # Run the tests + local test_passed=false + if run_test "$tutorial"; then + echo -e "${GREEN}โœ… PASSED: $tutorial${NC}" + ((PASSED++)) + test_passed=true + else + echo -e "${RED}โŒ FAILED: $tutorial${NC}" + ((FAILED++)) + FAILED_TESTS+=("$tutorial") + fi + + # Stop the agent + stop_agent "$tutorial" + + echo "" + + if [ "$test_passed" = true ]; then + return 0 + else + return 1 + fi +} + +# Main execution function +main() { + # Handle --view-logs flag + if [ "$VIEW_LOGS" = true ]; then + if [[ -n "$SINGLE_TUTORIAL" ]]; then + view_agent_logs "$SINGLE_TUTORIAL" + else + view_agent_logs + fi + exit 0 + fi + + echo "================================================================================" + if [[ -n "$SINGLE_TUTORIAL" ]]; then + echo "Running Single Tutorial Test: $SINGLE_TUTORIAL" + else + echo "Running All Agentic Tutorial Tests" + if [ "$CONTINUE_ON_ERROR" = true ]; then + echo -e "${YELLOW}โš ๏ธ Running in continue-on-error mode${NC}" + fi + fi + echo "================================================================================" + echo "" + + # Check prerequisites + check_prerequisites + + echo "" + + # Determine which tutorials to run + if [[ -n "$SINGLE_TUTORIAL" ]]; then + TUTORIALS=("$SINGLE_TUTORIAL") + else + TUTORIALS=("${ALL_TUTORIALS[@]}") + fi + + # Iterate over tutorials + for tutorial in "${TUTORIALS[@]}"; do + execute_tutorial_test "$tutorial" + + # Exit early if in fail-fast mode + if [ "$CONTINUE_ON_ERROR" = false ] && [ $FAILED -gt 0 ]; then + echo "" + echo -e "${RED}Stopping due to test failure. Use --continue-on-error to continue.${NC}" + exit 1 + fi + done + + # Print summary + echo "" + echo "================================================================================" + echo "Test Summary" + echo "================================================================================" + echo -e "Total: $((PASSED + FAILED))" + echo -e "${GREEN}Passed: $PASSED${NC}" + echo -e "${RED}Failed: $FAILED${NC}" + echo "" + + if [ $FAILED -gt 0 ]; then + echo "Failed tests:" + for test in "${FAILED_TESTS[@]}"; do + echo -e " ${RED}โœ—${NC} $test" + done + echo "" + exit 1 + else + echo -e "${GREEN}๐ŸŽ‰ All tests passed!${NC}" + echo "" + exit 0 + fi +} + +# Run main function +main diff --git a/examples/tutorials/test_utils/agentic.py b/examples/tutorials/test_utils/agentic.py new file mode 100644 index 00000000..33446160 --- /dev/null +++ b/examples/tutorials/test_utils/agentic.py @@ -0,0 +1,232 @@ +""" +Utility functions for testing AgentEx agentic agents. + +This module provides helper functions for working with agentic (non-temporal) agents, +including task creation, event sending, response polling, and streaming. +""" + +from agentex.types.agent_rpc_result import StreamTaskMessageDone +from agentex.types.agent_rpc_result import StreamTaskMessageFull +import asyncio +import json +from datetime import datetime, timezone +import time +from typing import AsyncGenerator, List, Optional, Tuple, Generator + +from click.formatting import measure_table + +from agentex._client import AsyncAgentex +from agentex.types.agent_rpc_params import ParamsSendEventRequest +from agentex.types.message_list_response import MessageListResponse +from agentex.types.task_message import TaskMessage +from agentex.types.text_content_param import TextContentParam + + +async def send_event_and_poll_yielding( + client: AsyncAgentex, + agent_id: str, + task_id: str, + user_message: str, + timeout: int = 30, + sleep_interval: float = 1.0, +) -> AsyncGenerator[TaskMessage, None]: + """ + Send an event to an agent and poll for responses, yielding messages as they arrive. + + Polls continuously until timeout is hit or the caller exits the loop. + + Args: + client: AgentEx client instance + agent_id: The agent ID + task_id: The task ID + user_message: The message content to send + timeout: Maximum seconds to wait for a response (default: 30) + sleep_interval: Seconds to sleep between polls (default: 1.0) + + Yields: + TaskMessage objects as they are discovered during polling + """ + # Send the event + event_content = TextContentParam(type="text", author="user", content=user_message) + + # Capture timestamp before sending to account for clock skew + # Subtract 1 second buffer to ensure we don't filter out messages we just created + messages_created_after = time.time() - 1.0 + + await client.agents.send_event( + agent_id=agent_id, params=ParamsSendEventRequest(task_id=task_id, content=event_content) + ) + # Poll continuously until timeout + # Poll for messages created after we sent the event + async for message in poll_messages( + client=client, + task_id=task_id, + timeout=timeout, + sleep_interval=sleep_interval, + messages_created_after=messages_created_after, + ): + yield message + + +async def poll_messages( + client: AsyncAgentex, + task_id: str, + timeout: int = 30, + sleep_interval: float = 1.0, + messages_created_after: Optional[float] = None, +) -> AsyncGenerator[TaskMessage, None]: + # Keep track of messages we've already yielded + seen_message_ids = set() + start_time = datetime.now() + + # Poll continuously until timeout + while (datetime.now() - start_time).seconds < timeout: + messages = await client.messages.list(task_id=task_id) + # print("DEBGUG: Messages found: ", messages) + new_messages_found = 0 + for message in messages: + # Skip if we've already yielded this message + if message.id in seen_message_ids: + continue + + # Check if message passes timestamp filter + if messages_created_after and message.created_at: + # If message.created_at is timezone-naive, assume it's UTC + if message.created_at.tzinfo is None: + msg_timestamp = message.created_at.replace(tzinfo=timezone.utc).timestamp() + else: + msg_timestamp = message.created_at.timestamp() + if msg_timestamp < messages_created_after: + continue + + # Yield new messages that pass the filter + seen_message_ids.add(message.id) + new_messages_found += 1 + + # This yield should transfer control back to the caller + yield message + + # If we see this print, it means the caller consumed the message and we resumed + # Sleep before next poll + await asyncio.sleep(sleep_interval) + + +async def send_event_and_stream( + client: AsyncAgentex, + agent_id: str, + task_id: str, + user_message: str, + timeout: int = 30, +): + """ + Send an event to an agent and stream the response, yielding events as they arrive. + + This function now uses stream_agent_response() under the hood and yields events + up the stack as they arrive. + + Args: + client: AgentEx client instance + agent_id: The agent ID + task_id: The task ID + user_message: The message content to send + timeout: Maximum seconds to wait for stream completion (default: 30) + + Yields: + Parsed event dictionaries as they arrive from the stream + + Raises: + Exception: If streaming fails + """ + # Send the event + event_content = TextContentParam(type="text", author="user", content=user_message) + + await client.agents.send_event(agent_id=agent_id, params={"task_id": task_id, "content": event_content}) + + # Stream the response using stream_agent_response and yield events up the stack + async for event in stream_agent_response( + client=client, + task_id=task_id, + timeout=timeout, + ): + yield event + + +async def stream_agent_response( + client: AsyncAgentex, + task_id: str, + timeout: int = 30, +): + """ + Stream the agent response for a given task, yielding events as they arrive. + + Args: + client: AgentEx client instance + task_id: The task ID to stream messages from + timeout: Maximum seconds to wait for stream completion (default: 30) + + Yields: + Parsed event dictionaries as they arrive from the stream + """ + try: + # Add explicit timeout wrapper to force exit after timeout seconds + async with asyncio.timeout(timeout): + async with client.tasks.with_streaming_response.stream_events(task_id=task_id, timeout=timeout) as stream: + async for line in stream.iter_lines(): + if line.startswith("data: "): + # Parse the SSE data + data = line.strip()[6:] # Remove "data: " prefix + event = json.loads(data) + # Yield each event immediately as it arrives + yield event + + except asyncio.TimeoutError: + print(f"[DEBUG] Stream timed out after {timeout}s") + except Exception as e: + print(f"[DEBUG] Stream error: {e}") + +async def stream_task_messages( + client: AsyncAgentex, + task_id: str, + timeout: int = 30, +) -> AsyncGenerator[TaskMessage, None]: + """ + Stream the task messages for a given task, yielding messages as they arrive. + """ + async for event in stream_agent_response( + client=client, + task_id=task_id, + timeout=timeout, + ): + msg_type = event.get("type") + task_message: Optional[TaskMessage] = None + if msg_type == "full": + task_message_update_full = StreamTaskMessageFull.model_validate(event) + if task_message_update_full.parent_task_message and task_message_update_full.parent_task_message.id: + finished_message = await client.messages.retrieve(task_message_update_full.parent_task_message.id) + task_message = finished_message + elif msg_type == "done": + task_message_update_done = StreamTaskMessageDone.model_validate(event) + if task_message_update_done.parent_task_message and task_message_update_done.parent_task_message.id: + finished_message = await client.messages.retrieve(task_message_update_done.parent_task_message.id) + task_message = finished_message + if task_message: + yield task_message + + + +def validate_text_in_response(expected_text: str, message: TaskMessage) -> bool: + """ + Validate that expected text appears in any of the messages. + + Args: + expected_text: The text to search for (case-insensitive) + messages: List of message objects to search + + Returns: + True if text is found, False otherwise + """ + for message in messages: + if message.content and message.content.type == "text": + if expected_text.lower() in message.content.content.lower(): + return True + return False diff --git a/examples/tutorials/test_utils/sync.py b/examples/tutorials/test_utils/sync.py new file mode 100644 index 00000000..8fc9e04d --- /dev/null +++ b/examples/tutorials/test_utils/sync.py @@ -0,0 +1,94 @@ +""" +Utility functions for testing AgentEx agents. + +This module provides helper functions for validating agent responses +in both streaming and non-streaming scenarios. +""" + +from typing import Callable, List, Optional, Generator + +from agentex.types import TextContent, TextDelta +from agentex.types.agent_rpc_response import SendMessageResponse +from agentex.types.agent_rpc_result import StreamTaskMessageDone +from agentex.types.task_message_update import StreamTaskMessageDelta, StreamTaskMessageFull + + +def validate_text_content(content: TextContent, validator: Optional[Callable[[str], bool]] = None) -> str: + """ + Validate that content is TextContent and optionally run a custom validator. + + Args: + content: The content to validate + validator: Optional function that takes the content string and returns True if valid + + Returns: + The text content as a string + + Raises: + AssertionError: If validation fails + """ + assert isinstance(content, TextContent), f"Expected TextContent, got {type(content)}" + assert isinstance(content.content, str), "Content should be a string" + + if validator: + assert validator(content.content), f"Content validation failed: {content.content}" + + return content.content + + +def validate_text_in_string(text_to_find: str, text: str): + """ + Validate that text is a string and optionally run a custom validator. + + Args: + text: The text to validate + validator: Optional function that takes the text string and returns True if valid + """ + + assert text_to_find in text, f"Expected to find '{text_to_find}' in text." + + +def collect_streaming_response( + stream_generator: Generator[SendMessageResponse, None, None], +) -> tuple[str, List[SendMessageResponse]]: + """ + Collect and validate a streaming response. + + Args: + stream_generator: The generator yielding streaming chunks + + Returns: + Tuple of (aggregated_content from deltas, full_content from full messages) + + Raises: + AssertionError: If no chunks are received or no content is found + """ + aggregated_content = "" + chunks = [] + + for chunk in stream_generator: + task_message_update = chunk.result + chunks.append(chunk) + # Collect text deltas as they arrive + if isinstance(task_message_update, StreamTaskMessageDelta) and task_message_update.delta is not None: + delta = task_message_update.delta + if isinstance(delta, TextDelta) and delta.text_delta is not None: + aggregated_content += delta.text_delta + + # Or collect full messages + elif isinstance(task_message_update, StreamTaskMessageFull): + content = task_message_update.content + if isinstance(content, TextContent): + aggregated_content = content.content + + elif isinstance(task_message_update, StreamTaskMessageDone): + # Handle non-streaming response case pattern + break + # Validate we received something + if not chunks: + raise AssertionError("No streaming chunks were received, when at least 1 was expected.") + + if not aggregated_content: + raise AssertionError("No content was received in the streaming response.") + + return aggregated_content, chunks diff --git a/src/agentex/lib/cli/templates/default/test_agent.py.j2 b/src/agentex/lib/cli/templates/default/test_agent.py.j2 new file mode 100644 index 00000000..27ff0e39 --- /dev/null +++ b/src/agentex/lib/cli/templates/default/test_agent.py.j2 @@ -0,0 +1,147 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: {{ agent_name }}) +""" + +import os +import uuid +import asyncio +import pytest +import pytest_asyncio +from agentex import AsyncAgentex +from agentex.types import TaskMessage +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam +from test_utils.agentic import ( + poll_for_agent_response, + send_event_and_poll_yielding, + stream_agent_response, + validate_text_in_response, + poll_messages, +) + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "{{ agent_name }}") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and polling for the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # TODO: Poll for the initial task creation message (if your agent sends one) + # async for message in poll_messages( + # client=client, + # task_id=task.id, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected initial message + # assert "expected initial text" in message.content.content + # break + + # TODO: Send an event and poll for response using the yielding helper function + # user_message = "Your test message here" + # async for message in send_event_and_poll_yielding( + # client=client, + # agent_id=agent_id, + # task_id=task.id, + # user_message=user_message, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected response + # assert "expected response text" in message.content.content + # break + pass + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and streaming the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # user_message = "Your test message here" + + # # Collect events from stream + # all_events = [] + + # async def collect_stream_events(): + # async for event in stream_agent_response( + # client=client, + # task_id=task.id, + # timeout=30, + # ): + # all_events.append(event) + + # # Start streaming task + # stream_task = asyncio.create_task(collect_stream_events()) + + # # Send the event + # event_content = TextContentParam(type="text", author="user", content=user_message) + # await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + + # # Wait for streaming to complete + # await stream_task + + # # TODO: Add your validation here + # assert len(all_events) > 0, "No events received in streaming response" + pass + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/src/agentex/lib/cli/templates/sync/test_agent.py.j2 b/src/agentex/lib/cli/templates/sync/test_agent.py.j2 new file mode 100644 index 00000000..996104a3 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync/test_agent.py.j2 @@ -0,0 +1,70 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming message sending +- Streaming message sending +- Task creation via RPC + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: {{ agent_name }}) +""" + +import os +import pytest +from agentex import Agentex + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "{{ agent_name }}") + + +@pytest.fixture +def client(): + """Create an AgentEx client instance for testing.""" + return Agentex(base_url=AGENTEX_API_BASE_URL) + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest.fixture +def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingMessages: + """Test non-streaming message sending.""" + + def test_send_message(self, client: Agentex, agent_name: str): + """Test sending a message and receiving a response.""" + # TODO: Fill in the test based on what data your agent is expected to handle + ... + + +class TestStreamingMessages: + """Test streaming message sending.""" + + def test_send_stream_message(self, client: Agentex, agent_name: str): + """Test streaming a message and aggregating deltas.""" + # TODO: Fill in the test based on what data your agent is expected to handle + ... + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/src/agentex/lib/cli/templates/temporal/test_agent.py.j2 b/src/agentex/lib/cli/templates/temporal/test_agent.py.j2 new file mode 100644 index 00000000..27ff0e39 --- /dev/null +++ b/src/agentex/lib/cli/templates/temporal/test_agent.py.j2 @@ -0,0 +1,147 @@ +""" +Sample tests for AgentEx ACP agent. + +This test suite demonstrates how to test the main AgentEx API functions: +- Non-streaming event sending and polling +- Streaming event sending + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: {{ agent_name }}) +""" + +import os +import uuid +import asyncio +import pytest +import pytest_asyncio +from agentex import AsyncAgentex +from agentex.types import TaskMessage +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest +from agentex.types.text_content_param import TextContentParam +from test_utils.agentic import ( + poll_for_agent_response, + send_event_and_poll_yielding, + stream_agent_response, + validate_text_in_response, + poll_messages, +) + + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "{{ agent_name }}") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +class TestNonStreamingEvents: + """Test non-streaming event sending and polling.""" + + @pytest.mark.asyncio + async def test_send_event_and_poll(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and polling for the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # TODO: Poll for the initial task creation message (if your agent sends one) + # async for message in poll_messages( + # client=client, + # task_id=task.id, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected initial message + # assert "expected initial text" in message.content.content + # break + + # TODO: Send an event and poll for response using the yielding helper function + # user_message = "Your test message here" + # async for message in send_event_and_poll_yielding( + # client=client, + # agent_id=agent_id, + # task_id=task.id, + # user_message=user_message, + # timeout=30, + # sleep_interval=1.0, + # ): + # assert isinstance(message, TaskMessage) + # if message.content and message.content.type == "text" and message.content.author == "agent": + # # Check for your expected response + # assert "expected response text" in message.content.content + # break + pass + + +class TestStreamingEvents: + """Test streaming event sending.""" + + @pytest.mark.asyncio + async def test_send_event_and_stream(self, client: AsyncAgentex, agent_name: str, agent_id: str): + """Test sending an event and streaming the response.""" + # TODO: Create a task for this conversation + # task_response = await client.agents.create_task(agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex)) + # task = task_response.result + # assert task is not None + + # user_message = "Your test message here" + + # # Collect events from stream + # all_events = [] + + # async def collect_stream_events(): + # async for event in stream_agent_response( + # client=client, + # task_id=task.id, + # timeout=30, + # ): + # all_events.append(event) + + # # Start streaming task + # stream_task = asyncio.create_task(collect_stream_events()) + + # # Send the event + # event_content = TextContentParam(type="text", author="user", content=user_message) + # await client.agents.send_event(agent_id=agent_id, params={"task_id": task.id, "content": event_content}) + + # # Wait for streaming to complete + # await stream_task + + # # TODO: Add your validation here + # assert len(all_events) > 0, "No events received in streaming response" + pass + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])