diff --git a/examples/tutorials/00_sync/010_multiturn/project/acp.py b/examples/tutorials/00_sync/010_multiturn/project/acp.py index b0d2098fb..5ae187542 100644 --- a/examples/tutorials/00_sync/010_multiturn/project/acp.py +++ b/examples/tutorials/00_sync/010_multiturn/project/acp.py @@ -88,6 +88,9 @@ async def handle_message_send( # Convert task messages to OpenAI Agents SDK format input_list = convert_task_messages_to_oai_agents_inputs(task_messages) + # Append the current user message (not yet persisted in task history) + input_list.append({"role": "user", "content": params.content.content}) + # Run the agent result = await Runner.run(test_agent, input_list, run_config=run_config) diff --git a/examples/tutorials/00_sync/010_multiturn/tests/test_agent.py b/examples/tutorials/00_sync/010_multiturn/tests/test_agent.py index 510e9159d..b0f05c1ad 100644 --- a/examples/tutorials/00_sync/010_multiturn/tests/test_agent.py +++ b/examples/tutorials/00_sync/010_multiturn/tests/test_agent.py @@ -17,6 +17,7 @@ """ import os +import uuid import pytest from test_utils.sync import validate_text_in_string, collect_streaming_response @@ -24,7 +25,6 @@ from agentex import Agentex from agentex.types import TextContent, TextContentParam from agentex.types.agent_rpc_params import ParamsCreateTaskRequest, ParamsSendMessageRequest -from agentex.lib.sdk.fastacp.base.base_acp_server import uuid # Configuration from environment variables AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") diff --git a/examples/tutorials/00_sync/020_streaming/project/acp.py b/examples/tutorials/00_sync/020_streaming/project/acp.py index 5f3d8e78e..8e7f5bcf9 100644 --- a/examples/tutorials/00_sync/020_streaming/project/acp.py +++ b/examples/tutorials/00_sync/020_streaming/project/acp.py @@ -56,6 +56,7 @@ async def handle_message_send( content="Hey, sorry I'm unable to respond to your message because you're running this example without an OpenAI API key. Please set the OPENAI_API_KEY environment variable to run this example. Do this by either by adding a .env file to the project/ directory or by setting the environment variable in your terminal.", ), ) + return # Try to retrieve the state. If it doesn't exist, create it. task_state = await adk.state.get_by_task_and_agent(task_id=params.task.id, agent_id=params.agent.id) @@ -86,6 +87,9 @@ async def handle_message_send( # Convert task messages to OpenAI Agents SDK format input_list = convert_task_messages_to_oai_agents_inputs(task_messages) + # Append the current user message (not yet persisted in task history) + input_list.append({"role": "user", "content": params.content.content}) + # Run the agent and stream the events result = Runner.run_streamed(test_agent, input_list, run_config=run_config) diff --git a/examples/tutorials/00_sync/020_streaming/tests/test_agent.py b/examples/tutorials/00_sync/020_streaming/tests/test_agent.py index b4ff65ff5..81c7e78aa 100644 --- a/examples/tutorials/00_sync/020_streaming/tests/test_agent.py +++ b/examples/tutorials/00_sync/020_streaming/tests/test_agent.py @@ -17,6 +17,7 @@ """ import os +import uuid import pytest from test_utils.sync import validate_text_in_string, collect_streaming_response @@ -24,7 +25,6 @@ from agentex import Agentex from agentex.types import TextContent, TextContentParam from agentex.types.agent_rpc_params import ParamsCreateTaskRequest, ParamsSendMessageRequest -from agentex.lib.sdk.fastacp.base.base_acp_server import uuid # Configuration from environment variables AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") diff --git a/examples/tutorials/00_sync/030_langgraph/project/graph.py b/examples/tutorials/00_sync/030_langgraph/project/graph.py index 53728cd58..32cbfb38c 100644 --- a/examples/tutorials/00_sync/030_langgraph/project/graph.py +++ b/examples/tutorials/00_sync/030_langgraph/project/graph.py @@ -20,7 +20,7 @@ from project.tools import TOOLS from agentex.lib.adk import create_checkpointer -MODEL_NAME = "gpt-5" +MODEL_NAME = "gpt-4o" SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. Current date and time: {timestamp} @@ -46,7 +46,6 @@ async def create_graph(): """ llm = ChatOpenAI( model=MODEL_NAME, - reasoning={"effort": "high", "summary": "auto"}, ) llm_with_tools = llm.bind_tools(TOOLS) diff --git a/examples/tutorials/00_sync/030_langgraph/tests/test_agent.py b/examples/tutorials/00_sync/030_langgraph/tests/test_agent.py index 36fcf418f..03acf9cbf 100644 --- a/examples/tutorials/00_sync/030_langgraph/tests/test_agent.py +++ b/examples/tutorials/00_sync/030_langgraph/tests/test_agent.py @@ -16,6 +16,7 @@ """ import os +import uuid import pytest from test_utils.sync import validate_text_in_string, collect_streaming_response @@ -23,7 +24,6 @@ from agentex import Agentex from agentex.types import TextContent, TextContentParam from agentex.types.agent_rpc_params import ParamsCreateTaskRequest, ParamsSendMessageRequest -from agentex.lib.sdk.fastacp.base.base_acp_server import uuid # Configuration from environment variables AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") diff --git a/examples/tutorials/10_async/00_base/010_multiturn/project/acp.py b/examples/tutorials/10_async/00_base/010_multiturn/project/acp.py index a32eed68e..4bf080d4f 100644 --- a/examples/tutorials/10_async/00_base/010_multiturn/project/acp.py +++ b/examples/tutorials/10_async/00_base/010_multiturn/project/acp.py @@ -94,6 +94,7 @@ async def handle_event_send(params: SendEventParams): content="Hey, sorry I'm unable to respond to your message because you're running this example without an OpenAI API key. Please set the OPENAI_API_KEY environment variable to run this example. Do this by either by adding a .env file to the project/ directory or by setting the environment variable in your terminal.", ), ) + return ######################################################### # 5. (👋) Retrieve the task state. diff --git a/examples/tutorials/10_async/00_base/010_multiturn/tests/test_agent.py b/examples/tutorials/10_async/00_base/010_multiturn/tests/test_agent.py index 33d831858..43d283b8c 100644 --- a/examples/tutorials/10_async/00_base/010_multiturn/tests/test_agent.py +++ b/examples/tutorials/10_async/00_base/010_multiturn/tests/test_agent.py @@ -145,6 +145,7 @@ async def test_send_event_and_stream(self, client: AsyncAgentex, agent_id: str): task = task_response.result assert task is not None + await asyncio.sleep(1) # wait for state to be initialized # Check initial state states = await client.states.list(agent_id=agent_id, task_id=task.id) assert len(states) == 1 diff --git a/examples/tutorials/10_async/00_base/020_streaming/project/acp.py b/examples/tutorials/10_async/00_base/020_streaming/project/acp.py index 41e44912e..483d4ea5f 100644 --- a/examples/tutorials/10_async/00_base/020_streaming/project/acp.py +++ b/examples/tutorials/10_async/00_base/020_streaming/project/acp.py @@ -76,6 +76,7 @@ async def handle_event_send(params: SendEventParams): content="Hey, sorry I'm unable to respond to your message because you're running this example without an OpenAI API key. Please set the OPENAI_API_KEY environment variable to run this example. Do this by either by adding a .env file to the project/ directory or by setting the environment variable in your terminal.", ), ) + return ######################################################### # 5. Retrieve the task state. diff --git a/examples/tutorials/10_async/00_base/030_tracing/project/acp.py b/examples/tutorials/10_async/00_base/030_tracing/project/acp.py index a46e77698..aca122164 100644 --- a/examples/tutorials/10_async/00_base/030_tracing/project/acp.py +++ b/examples/tutorials/10_async/00_base/030_tracing/project/acp.py @@ -117,6 +117,7 @@ async def handle_event_send(params: SendEventParams): ), parent_span_id=span.id if span else None, ) + return ######################################################### # 7. Call an LLM to respond to the user's message diff --git a/examples/tutorials/10_async/00_base/040_other_sdks/project/acp.py b/examples/tutorials/10_async/00_base/040_other_sdks/project/acp.py index d2ec84fcd..e860f5ef0 100644 --- a/examples/tutorials/10_async/00_base/040_other_sdks/project/acp.py +++ b/examples/tutorials/10_async/00_base/040_other_sdks/project/acp.py @@ -111,6 +111,7 @@ async def handle_event_send(params: SendEventParams): ), parent_span_id=span.id if span else None, ) + return ######################################################### # (👋) Call an LLM to respond to the user's message using custom streaming diff --git a/examples/tutorials/10_async/00_base/100_langgraph/tests/test_agent.py b/examples/tutorials/10_async/00_base/100_langgraph/tests/test_agent.py index 948db1558..d88e1dc4d 100644 --- a/examples/tutorials/10_async/00_base/100_langgraph/tests/test_agent.py +++ b/examples/tutorials/10_async/00_base/100_langgraph/tests/test_agent.py @@ -16,6 +16,7 @@ """ import os +import uuid import pytest import pytest_asyncio @@ -23,7 +24,6 @@ from agentex import AsyncAgentex from agentex.types import TextContentParam from agentex.types.agent_rpc_params import ParamsCreateTaskRequest -from agentex.lib.sdk.fastacp.base.base_acp_server import uuid # Configuration from environment variables AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") diff --git a/examples/tutorials/10_async/10_temporal/010_agent_chat/project/workflow.py b/examples/tutorials/10_async/10_temporal/010_agent_chat/project/workflow.py index 3e3ac5b27..ced7ec5dc 100644 --- a/examples/tutorials/10_async/10_temporal/010_agent_chat/project/workflow.py +++ b/examples/tutorials/10_async/10_temporal/010_agent_chat/project/workflow.py @@ -216,6 +216,7 @@ async def on_task_event_send(self, params: SendEventParams) -> None: ), parent_span_id=span.id if span else None, ) + return # Call an LLM to respond to the user's message # When send_as_agent_task_message=True, returns a TaskMessage @@ -234,13 +235,7 @@ async def on_task_event_send(self, params: SendEventParams) -> None: "to provide accurate and well-reasoned responses." ), parent_span_id=span.id if span else None, - model="gpt-5", - model_settings=ModelSettings( - # Include reasoning items in the response (IDs, summaries) - # response_include=["reasoning.encrypted_content"], - # Ask the model to include a short reasoning summary - reasoning=Reasoning(effort="medium", summary="detailed"), - ), + model="gpt-4o", # tools=[CALCULATOR_TOOL], ) if self._state: diff --git a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/workflow.py b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/workflow.py index b54c8fade..508c2e03e 100644 --- a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/workflow.py +++ b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/workflow.py @@ -408,6 +408,7 @@ async def on_task_event_send(self, params: SendEventParams) -> None: ), parent_span_id=span.id if span else None, ) + return # Call an LLM to respond to the user's message # When send_as_agent_task_message=True, returns a TaskMessage diff --git a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/workflow.py b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/workflow.py index 4f11ac4c0..a32e4ad1a 100644 --- a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/workflow.py +++ b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/workflow.py @@ -204,7 +204,8 @@ async def on_task_event_send(self, params: SendEventParams) -> None: self._state.input_list.append(msg) # Set span output for tracing - include full state - span.output = self._state.model_dump() + if span: + span.output = self._state.model_dump() @workflow.run async def on_task_create(self, params: CreateTaskParams) -> str: diff --git a/examples/tutorials/run_agent_test.sh b/examples/tutorials/run_agent_test.sh index f396cfd00..b25f63222 100755 --- a/examples/tutorials/run_agent_test.sh +++ b/examples/tutorials/run_agent_test.sh @@ -259,6 +259,19 @@ run_test() { cd "$tutorial_path" || return 1 + # Determine pytest command - use built wheel if available (same wheel used to start agent) + local pytest_cmd="uv run pytest" + if [ "$BUILD_CLI" = true ]; then + local wheel_file=$(ls /home/runner/work/*/*/dist/agentex_sdk-*.whl 2>/dev/null | head -n1) + if [[ -z "$wheel_file" ]]; then + # Fallback for local development + wheel_file=$(ls "${SCRIPT_DIR}/../../dist/agentex_sdk-*.whl" 2>/dev/null | head -n1) + fi + if [[ -n "$wheel_file" ]]; then + pytest_cmd="uv run --with $wheel_file pytest" + fi + fi + # Run the tests with retry mechanism local max_retries=5 local retry_count=0 @@ -270,7 +283,7 @@ run_test() { fi # Stream pytest output directly in real-time - uv run pytest tests/test_agent.py -v -s + $pytest_cmd tests/test_agent.py -v -s exit_code=$? if [ $exit_code -eq 0 ]; then diff --git a/pyproject.toml b/pyproject.toml index 17e758d54..6d3d23b15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,9 @@ dependencies = [ "typer>=0.16,<0.17", "questionary>=2.0.1,<3", "rich>=13.9.2,<14", - "fastapi>=0.115.0,<0.116", + "fastapi>=0.115.0", # upper bound removed — CVE-2025-62727 fix needs starlette>=0.49.1 + "starlette>=0.49.1", # CVE-2025-62727 (HIGH) — floor 0.49.1 patches vuln + "tornado>=6.5.5", # CVE-2026-31958 (HIGH) — floor pin ensures patched release "uvicorn>=0.31.1", "watchfiles>=0.24.0,<1.0", "python-on-whales>=0.73.0,<0.74", @@ -26,6 +28,8 @@ dependencies = [ "jsonschema>=4.23.0,<5", "jsonref>=1.1.0,<2", "temporalio>=1.18.2,<2", + "opentelemetry-api>=1.11.1,<2", + "opentelemetry-sdk>=1.11.1,<2", "aiohttp>=3.10.10,<4", "redis>=5.2.0,<6", "litellm>=1.66.0,<2", diff --git a/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/hooks.py b/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/hooks.py index 795d44a0a..798481ba6 100644 --- a/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/hooks.py +++ b/src/agentex/lib/core/temporal/plugins/openai_agents/hooks/hooks.py @@ -131,7 +131,7 @@ async def on_tool_start(self, context: RunContextWrapper, agent: Agent, tool: To logger.warning(f"Failed to parse tool arguments: {tool_context.tool_arguments}") tool_arguments = {} - await workflow.execute_activity_method( + await workflow.execute_activity( stream_lifecycle_content, args=[ self.task_id, @@ -167,7 +167,7 @@ async def on_tool_end( else f"call_{id(tool)}" ) - await workflow.execute_activity_method( + await workflow.execute_activity( stream_lifecycle_content, args=[ self.task_id, @@ -195,7 +195,7 @@ async def on_handoff( from_agent: The agent transferring control to_agent: The agent receiving control """ - await workflow.execute_activity_method( + await workflow.execute_activity( stream_lifecycle_content, args=[ self.task_id, diff --git a/src/agentex/lib/core/temporal/workers/worker.py b/src/agentex/lib/core/temporal/workers/worker.py index 28cab2e14..9a6e8ea53 100644 --- a/src/agentex/lib/core/temporal/workers/worker.py +++ b/src/agentex/lib/core/temporal/workers/worker.py @@ -24,7 +24,6 @@ DefaultPayloadConverter, CompositePayloadConverter, JSONPlainPayloadConverter, - _JSONTypeConverterUnhandled, ) from temporalio.contrib.openai_agents import OpenAIAgentsPlugin @@ -45,7 +44,7 @@ def default(self, o: Any) -> Any: class DateTimeJSONTypeConverter(JSONTypeConverter): @override - def to_typed_value(self, hint: type, value: Any) -> Any | None | _JSONTypeConverterUnhandled: + def to_typed_value(self, hint: type, value: Any) -> Any: if hint == datetime.datetime: return datetime.datetime.fromisoformat(value) return JSONTypeConverter.Unhandled diff --git a/src/agentex/lib/sdk/fastacp/base/base_acp_server.py b/src/agentex/lib/sdk/fastacp/base/base_acp_server.py index b625eaa1c..1ecadf3a3 100644 --- a/src/agentex/lib/sdk/fastacp/base/base_acp_server.py +++ b/src/agentex/lib/sdk/fastacp/base/base_acp_server.py @@ -11,8 +11,8 @@ import uvicorn from fastapi import FastAPI, Request from pydantic import TypeAdapter, ValidationError +from starlette.types import Send, Scope, ASGIApp, Receive from fastapi.responses import StreamingResponse -from starlette.middleware.base import BaseHTTPMiddleware from agentex.lib.types.acp import ( RPC_SYNC_METHODS, @@ -43,17 +43,25 @@ task_message_update_adapter = TypeAdapter(TaskMessageUpdate) -class RequestIDMiddleware(BaseHTTPMiddleware): - """Middleware to extract or generate request IDs and add them to logs and response headers""" +class RequestIDMiddleware: + """Pure ASGI middleware to extract or generate request IDs and set them in the logging context. - async def dispatch(self, request: Request, call_next): # type: ignore[override] - # Extract request ID from header or generate a new one if there isn't one - request_id = request.headers.get("x-request-id") or uuid.uuid4().hex - # Store request ID in request state for access in handlers - ctx_var_request_id.set(request_id) - # Process request - response = await call_next(request) - return response + Implemented as a pure ASGI middleware (rather than BaseHTTPMiddleware) so that it never + buffers the response body. BaseHTTPMiddleware's call_next() silently swallows + StreamingResponse bodies in several starlette versions, which caused message/send handlers + to return result=null through the Agentex server proxy. + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] == "http": + headers = dict(scope.get("headers", [])) + raw_request_id = headers.get(b"x-request-id", b"") + request_id = raw_request_id.decode() if raw_request_id else uuid.uuid4().hex + ctx_var_request_id.set(request_id) + await self.app(scope, receive, send) class BaseACPServer(FastAPI): diff --git a/src/agentex/lib/sdk/fastacp/tests/test_base_acp_server.py b/src/agentex/lib/sdk/fastacp/tests/test_base_acp_server.py index 0816ac436..768d1b1e1 100644 --- a/src/agentex/lib/sdk/fastacp/tests/test_base_acp_server.py +++ b/src/agentex/lib/sdk/fastacp/tests/test_base_acp_server.py @@ -126,11 +126,20 @@ async def mock_handler(params): "jsonrpc": "2.0", "method": "event/send", "params": { - "task": {"id": "test-task", "agent_id": "test-agent", "status": "RUNNING"}, - "message": { - "type": "text", - "author": "user", - "content": "test message", + "agent": { + "id": "test-agent-456", + "name": "test-agent", + "description": "test agent", + "acp_type": "sync", + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-01T00:00:00Z", + }, + "task": {"id": "test-task"}, + "event": { + "id": "evt-1", + "agent_id": "test-agent-456", + "sequence_id": 1, + "task_id": "test-task", }, }, "id": "test-1", @@ -218,11 +227,20 @@ async def mock_handler(params): "jsonrpc": "2.0", "method": "event/send", "params": { - "task": {"id": "test-task", "agent_id": "test-agent", "status": "RUNNING"}, - "message": { - "type": "text", - "author": "user", - "content": "test message", + "agent": { + "id": "test-agent-456", + "name": "test-agent", + "description": "test agent", + "acp_type": "sync", + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-01T00:00:00Z", + }, + "task": {"id": "test-task"}, + "event": { + "id": "evt-1", + "agent_id": "test-agent-456", + "sequence_id": 1, + "task_id": "test-task", }, }, # No ID = notification @@ -256,7 +274,17 @@ async def mock_handler(params): request = { "jsonrpc": "2.0", "method": "task/cancel", - "params": {"task_id": "test-task-123"}, + "params": { + "agent": { + "id": "test-agent-456", + "name": "test-agent", + "description": "test agent", + "acp_type": "sync", + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-01T00:00:00Z", + }, + "task": {"id": "test-task-123"}, + }, "id": "test-request-1", } @@ -280,7 +308,7 @@ def test_send_message_synchronous_response(self, base_acp_server): async def mock_execute_handler(params): return { "task_id": params.task.id, - "message_content": params.message.content, + "message_content": params.content.content, "status": "executed_synchronously", "custom_data": {"processed": True, "timestamp": "2024-01-01T12:00:00Z"}, } @@ -291,8 +319,16 @@ async def mock_execute_handler(params): "jsonrpc": "2.0", "method": "message/send", "params": { - "task": {"id": "test-task-123", "agent_id": "test-agent", "status": "RUNNING"}, - "message": { + "agent": { + "id": "test-agent-456", + "name": "test-agent", + "description": "test agent", + "acp_type": "sync", + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-01T00:00:00Z", + }, + "task": {"id": "test-task-123"}, + "content": { "type": "text", "author": "user", "content": "Execute this task please", @@ -340,7 +376,15 @@ async def mock_init_handler(params): "jsonrpc": "2.0", "method": "task/create", "params": { - "task": {"id": "test-task-456", "agent_id": "test-agent", "status": "RUNNING"} + "agent": { + "id": "test-agent-456", + "name": "test-agent", + "description": "test agent", + "acp_type": "sync", + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-01T00:00:00Z", + }, + "task": {"id": "test-task-456"}, }, "id": "test-init-1", } @@ -428,11 +472,20 @@ async def failing_handler(params): "jsonrpc": "2.0", "method": "event/send", "params": { - "task": {"id": "test-task", "agent_id": "test-agent", "status": "RUNNING"}, - "message": { - "type": "text", - "author": "user", - "content": "test message", + "agent": { + "id": "test-agent-456", + "name": "test-agent", + "description": "test agent", + "acp_type": "sync", + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-01T00:00:00Z", + }, + "task": {"id": "test-task"}, + "event": { + "id": "evt-1", + "agent_id": "test-agent-456", + "sequence_id": 1, + "task_id": "test-task", }, }, "id": "test-1", diff --git a/src/agentex/resources/agents.py b/src/agentex/resources/agents.py index ae6821e76..9a3c7ad62 100644 --- a/src/agentex/resources/agents.py +++ b/src/agentex/resources/agents.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from typing import Union, Optional, Generator, AsyncGenerator +from typing import Any, Union, Optional, Generator, AsyncGenerator from typing_extensions import Literal import httpx @@ -453,38 +453,73 @@ def send_message( ) -> SendMessageResponse: if agent_id is not None and agent_name is not None: raise ValueError("Either agent_id or agent_name must be provided, but not both") - + if "stream" in params and params["stream"] == True: raise ValueError("If stream is set to True, use send_message_stream() instead") + + if agent_id is not None: + streaming_response = self.with_streaming_response.rpc( + agent_id=agent_id, + method="message/send", + params=params, + id=id, + jsonrpc=jsonrpc, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + elif agent_name is not None: + streaming_response = self.with_streaming_response.rpc_by_name( + agent_name=agent_name, + method="message/send", + params=params, + id=id, + jsonrpc=jsonrpc, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) else: - if agent_id is not None: - raw_agent_rpc_response = self.rpc( - agent_id=agent_id, - method="message/send", - params=params, - id=id, - jsonrpc=jsonrpc, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - elif agent_name is not None: - raw_agent_rpc_response = self.rpc_by_name( - agent_name=agent_name, - method="message/send", - params=params, - id=id, - jsonrpc=jsonrpc, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - else: - raise ValueError("Either agent_id or agent_name must be provided") - - return SendMessageResponse.model_validate(raw_agent_rpc_response, from_attributes=True) + raise ValueError("Either agent_id or agent_name must be provided") + + task_messages: list[Any] = [] + response_meta: dict[str, Any] = {} + + with streaming_response as response: + for _line in response.iter_lines(): + if not _line: + continue + line = _line.strip() + if line.startswith("data:"): + line = line[len("data:"):].strip() + if not line: + continue + try: + chunk = json.loads(line) + if not response_meta: + response_meta = {"id": chunk.get("id"), "jsonrpc": chunk.get("jsonrpc")} + # If the server already aggregated into a list result, return directly + try: + return SendMessageResponse.model_validate(chunk) + except ValidationError: + pass + # Parse as a streaming event and collect parent_task_message + chunk_stream = SendMessageStreamResponse.model_validate(chunk, from_attributes=True) + result = chunk_stream.result + if result is not None and hasattr(result, 'type') and result.type == 'full': + parent = getattr(result, 'parent_task_message', None) + if parent is not None: + task_messages.append(parent) + except (json.JSONDecodeError, ValidationError): + continue + + return SendMessageResponse( + id=response_meta.get("id"), + jsonrpc=response_meta.get("jsonrpc"), + result=task_messages, + ) def send_message_stream( self, @@ -552,10 +587,10 @@ def send_message_stream( from_attributes=True ) yield chunk_rpc_response - except json.JSONDecodeError: - # Skip invalid JSON lines + except (json.JSONDecodeError, ValidationError): + # Skip invalid JSON lines or lines that can't be validated continue - + def send_event( self, agent_id: str | None = None, @@ -1021,38 +1056,73 @@ async def send_message( ) -> SendMessageResponse: if agent_id is not None and agent_name is not None: raise ValueError("Either agent_id or agent_name must be provided, but not both") - + if "stream" in params and params["stream"] == True: raise ValueError("If stream is set to True, use send_message_stream() instead") + + if agent_id is not None: + streaming_response = self.with_streaming_response.rpc( + agent_id=agent_id, + method="message/send", + params=params, + id=id, + jsonrpc=jsonrpc, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + elif agent_name is not None: + streaming_response = self.with_streaming_response.rpc_by_name( + agent_name=agent_name, + method="message/send", + params=params, + id=id, + jsonrpc=jsonrpc, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) else: - if agent_id is not None: - raw_agent_rpc_response = await self.rpc( - agent_id=agent_id, - method="message/send", - params=params, - id=id, - jsonrpc=jsonrpc, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - elif agent_name is not None: - raw_agent_rpc_response = await self.rpc_by_name( - agent_name=agent_name, - method="message/send", - params=params, - id=id, - jsonrpc=jsonrpc, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - else: - raise ValueError("Either agent_id or agent_name must be provided") - - return SendMessageResponse.model_validate(raw_agent_rpc_response, from_attributes=True) + raise ValueError("Either agent_id or agent_name must be provided") + + task_messages: list[Any] = [] + response_meta: dict[str, Any] = {} + + async with streaming_response as response: + async for _line in response.iter_lines(): + if not _line: + continue + line = _line.strip() + if line.startswith("data:"): + line = line[len("data:"):].strip() + if not line: + continue + try: + chunk = json.loads(line) + if not response_meta: + response_meta = {"id": chunk.get("id"), "jsonrpc": chunk.get("jsonrpc")} + # If the server already aggregated into a list result, return directly + try: + return SendMessageResponse.model_validate(chunk) + except ValidationError: + pass + # Parse as a streaming event and collect parent_task_message + chunk_stream = SendMessageStreamResponse.model_validate(chunk, from_attributes=True) + result = chunk_stream.result + if result is not None and hasattr(result, 'type') and result.type == 'full': + parent = getattr(result, 'parent_task_message', None) + if parent is not None: + task_messages.append(parent) + except (json.JSONDecodeError, ValidationError): + continue + + return SendMessageResponse( + id=response_meta.get("id"), + jsonrpc=response_meta.get("jsonrpc"), + result=task_messages, + ) async def send_message_stream( self, diff --git a/src/agentex/types/agent_rpc_response.py b/src/agentex/types/agent_rpc_response.py index 84fbab706..31fe5ec3b 100644 --- a/src/agentex/types/agent_rpc_response.py +++ b/src/agentex/types/agent_rpc_response.py @@ -40,7 +40,7 @@ class SendMessageResponse(BaseAgentRpcResponse): """The result of the message sending""" class SendMessageStreamResponse(BaseAgentRpcResponse): - result: TaskMessageUpdate + result: Optional[TaskMessageUpdate] = None """The result of the message sending""" diff --git a/uv.lock b/uv.lock index 828726620..8a85c5436 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ [[package]] name = "agentex-sdk" -version = "0.9.3" +version = "0.9.4" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -32,6 +32,8 @@ dependencies = [ { name = "mcp", extra = ["cli"] }, { name = "openai" }, { name = "openai-agents" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, { name = "pydantic" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -43,7 +45,9 @@ dependencies = [ { name = "scale-gp" }, { name = "scale-gp-beta" }, { name = "sniffio" }, + { name = "starlette" }, { name = "temporalio" }, + { name = "tornado" }, { name = "typer" }, { name = "typing-extensions" }, { name = "tzdata" }, @@ -80,7 +84,7 @@ requires-dist = [ { name = "datadog", specifier = ">=0.52.1" }, { name = "ddtrace", specifier = ">=3.13.0" }, { name = "distro", specifier = ">=1.7.0,<2" }, - { name = "fastapi", specifier = ">=0.115.0,<0.116" }, + { name = "fastapi", specifier = ">=0.115.0" }, { name = "httpx", specifier = ">=0.27.2,<0.28" }, { name = "httpx-aiohttp", marker = "extra == 'aiohttp'", specifier = ">=0.1.9" }, { name = "ipykernel", specifier = ">=6.29.5" }, @@ -94,6 +98,8 @@ requires-dist = [ { name = "mcp", extras = ["cli"], specifier = ">=1.4.1" }, { name = "openai", specifier = ">=2.2,<3" }, { name = "openai-agents", specifier = "==0.4.2" }, + { name = "opentelemetry-api", specifier = ">=1.11.1,<2" }, + { name = "opentelemetry-sdk", specifier = ">=1.11.1,<2" }, { name = "pydantic", specifier = ">=2.0.0,<3" }, { name = "pytest", specifier = ">=8.4.0" }, { name = "pytest-asyncio", specifier = ">=1.0.0" }, @@ -106,7 +112,9 @@ requires-dist = [ { name = "scale-gp", specifier = ">=0.1.0a59" }, { name = "scale-gp-beta", specifier = ">=0.1.0a20" }, { name = "sniffio" }, + { name = "starlette", specifier = ">=0.49.1" }, { name = "temporalio", specifier = ">=1.18.2,<2" }, + { name = "tornado", specifier = ">=6.5.5" }, { name = "typer", specifier = ">=0.16,<0.17" }, { name = "typing-extensions", specifier = ">=4.10,<5" }, { name = "tzdata", specifier = ">=2025.2" }, @@ -197,6 +205,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -564,16 +581,18 @@ wheels = [ [[package]] name = "fastapi" -version = "0.115.14" +version = "0.135.2" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "annotated-doc" }, { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/53/8c38a874844a8b0fa10dd8adf3836ac154082cf88d3f22b544e9ceea0a15/fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739", size = 296263, upload-time = "2025-06-26T15:29:08.21Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca", size = 95514, upload-time = "2025-06-26T15:29:06.49Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, ] [[package]] @@ -1489,6 +1508,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/48/28ed9e55dcf2f453128df738210a980e09f4e468a456fa3c763dbc8be70a/opentelemetry_api-1.37.0-py3-none-any.whl", hash = "sha256:accf2024d3e89faec14302213bc39550ec0f4095d1cf5ca688e1bfb1c8612f47", size = 65732, upload-time = "2025-09-11T10:28:41.826Z" }, ] +[[package]] +name = "opentelemetry-sdk" +version = "1.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/62/2e0ca80d7fe94f0b193135375da92c640d15fe81f636658d2acf373086bc/opentelemetry_sdk-1.37.0.tar.gz", hash = "sha256:cc8e089c10953ded765b5ab5669b198bbe0af1b3f89f1007d19acd32dc46dda5", size = 170404, upload-time = "2025-09-11T10:29:11.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/62/9f4ad6a54126fb00f7ed4bb5034964c6e4f00fcd5a905e115bd22707e20d/opentelemetry_sdk-1.37.0-py3-none-any.whl", hash = "sha256:8f3c3c22063e52475c5dbced7209495c2c16723d016d39287dfc215d1771257c", size = 131941, upload-time = "2025-09-11T10:28:57.83Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.58b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/1b/90701d91e6300d9f2fb352153fb1721ed99ed1f6ea14fa992c756016e63a/opentelemetry_semantic_conventions-0.58b0.tar.gz", hash = "sha256:6bd46f51264279c433755767bb44ad00f1c9e2367e1b42af563372c5a6fa0c25", size = 129867, upload-time = "2025-09-11T10:29:12.597Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/90/68152b7465f50285d3ce2481b3aec2f82822e3f52e5152eeeaf516bab841/opentelemetry_semantic_conventions-0.58b0-py3-none-any.whl", hash = "sha256:5564905ab1458b96684db1340232729fce3b5375a06e140e8904c78e4f815b28", size = 207954, upload-time = "2025-09-11T10:28:59.218Z" }, +] + [[package]] name = "orjson" version = "3.11.7" @@ -2422,14 +2468,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.46.2" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] [[package]] @@ -2520,21 +2567,19 @@ wheels = [ [[package]] name = "tornado" -version = "6.5.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/ce/1eb500eae19f4648281bb2186927bb062d2438c2e5093d1360391afd2f90/tornado-6.5.2.tar.gz", hash = "sha256:ab53c8f9a0fa351e2c0741284e06c7a45da86afb544133201c5cc8578eb076a0", size = 510821, upload-time = "2025-08-08T18:27:00.78Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/48/6a7529df2c9cc12efd2e8f5dd219516184d703b34c06786809670df5b3bd/tornado-6.5.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2436822940d37cde62771cff8774f4f00b3c8024fe482e16ca8387b8a2724db6", size = 442563, upload-time = "2025-08-08T18:26:42.945Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b5/9b575a0ed3e50b00c40b08cbce82eb618229091d09f6d14bce80fc01cb0b/tornado-6.5.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:583a52c7aa94ee046854ba81d9ebb6c81ec0fd30386d96f7640c96dad45a03ef", size = 440729, upload-time = "2025-08-08T18:26:44.473Z" }, - { url = "https://files.pythonhosted.org/packages/1b/4e/619174f52b120efcf23633c817fd3fed867c30bff785e2cd5a53a70e483c/tornado-6.5.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0fe179f28d597deab2842b86ed4060deec7388f1fd9c1b4a41adf8af058907e", size = 444295, upload-time = "2025-08-08T18:26:46.021Z" }, - { url = "https://files.pythonhosted.org/packages/95/fa/87b41709552bbd393c85dd18e4e3499dcd8983f66e7972926db8d96aa065/tornado-6.5.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b186e85d1e3536d69583d2298423744740986018e393d0321df7340e71898882", size = 443644, upload-time = "2025-08-08T18:26:47.625Z" }, - { url = "https://files.pythonhosted.org/packages/f9/41/fb15f06e33d7430ca89420283a8762a4e6b8025b800ea51796ab5e6d9559/tornado-6.5.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e792706668c87709709c18b353da1f7662317b563ff69f00bab83595940c7108", size = 443878, upload-time = "2025-08-08T18:26:50.599Z" }, - { url = "https://files.pythonhosted.org/packages/11/92/fe6d57da897776ad2e01e279170ea8ae726755b045fe5ac73b75357a5a3f/tornado-6.5.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:06ceb1300fd70cb20e43b1ad8aaee0266e69e7ced38fa910ad2e03285009ce7c", size = 444549, upload-time = "2025-08-08T18:26:51.864Z" }, - { url = "https://files.pythonhosted.org/packages/9b/02/c8f4f6c9204526daf3d760f4aa555a7a33ad0e60843eac025ccfd6ff4a93/tornado-6.5.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:74db443e0f5251be86cbf37929f84d8c20c27a355dd452a5cfa2aada0d001ec4", size = 443973, upload-time = "2025-08-08T18:26:53.625Z" }, - { url = "https://files.pythonhosted.org/packages/ae/2d/f5f5707b655ce2317190183868cd0f6822a1121b4baeae509ceb9590d0bd/tornado-6.5.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b5e735ab2889d7ed33b32a459cac490eda71a1ba6857b0118de476ab6c366c04", size = 443954, upload-time = "2025-08-08T18:26:55.072Z" }, - { url = "https://files.pythonhosted.org/packages/e8/59/593bd0f40f7355806bf6573b47b8c22f8e1374c9b6fd03114bd6b7a3dcfd/tornado-6.5.2-cp39-abi3-win32.whl", hash = "sha256:c6f29e94d9b37a95013bb669616352ddb82e3bfe8326fccee50583caebc8a5f0", size = 445023, upload-time = "2025-08-08T18:26:56.677Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2a/f609b420c2f564a748a2d80ebfb2ee02a73ca80223af712fca591386cafb/tornado-6.5.2-cp39-abi3-win_amd64.whl", hash = "sha256:e56a5af51cc30dd2cae649429af65ca2f6571da29504a07995175df14c18f35f", size = 445427, upload-time = "2025-08-08T18:26:57.91Z" }, - { url = "https://files.pythonhosted.org/packages/5e/4f/e1f65e8f8c76d73658b33d33b81eed4322fb5085350e4328d5c956f0c8f9/tornado-6.5.2-cp39-abi3-win_arm64.whl", hash = "sha256:d6c33dc3672e3a1f3618eb63b7ef4683a7688e7b9e6e8f0d9aa5726360a004af", size = 444456, upload-time = "2025-08-08T18:26:59.207Z" }, +version = "6.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" }, + { url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" }, + { url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" }, + { url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" }, + { url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" }, + { url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" }, ] [[package]]