diff --git a/.DS_Store b/.DS_Store index 8600de0..bbce78c 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.trivyignore b/.trivyignore index 7d02749..e769bb9 100644 --- a/.trivyignore +++ b/.trivyignore @@ -16,4 +16,20 @@ CVE-2023-45853 # Aucune version fixée disponible dans Debian bookworm (Candidate == Installed: 3.7.9-2+deb12u6). # L'application n'utilise pas DTLS (FastAPI/HTTP + WebSocket over TCP). # Review: 2026-05-06 -CVE-2026-33845 \ No newline at end of file +CVE-2026-33845 + +# perl-base: CVE-2026-42496 - Path traversal via symlinks dans Archive::Tar. +# Aucune version fixée dans Debian bookworm (status: ). +# L'application n'utilise pas Perl ; perl-base est une dépendance système +# des outils Debian (dpkg, apt). Fix upstream: Archive::Tar 3.08. +# Tracker: https://security-tracker.debian.org/tracker/CVE-2026-42496 +# Review: 2026-06-18 +CVE-2026-42496 + +# perl-base: CVE-2026-8376 - Heap buffer overflow dans le compileur de regex. +# N'affecte que les builds 32-bit (image amd64/arm64 non concernée -> faux positif). +# Aucune version fixée dans bookworm/trixie (status: ). +# Fix uniquement dans unstable (perl 5.40.1-8). +# Tracker: https://security-tracker.debian.org/tracker/CVE-2026-8376 +# Review: 2026-06-18 +CVE-2026-8376 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 5422d74..4fd0944 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,16 +11,17 @@ version = "0.1.0" description = "Composable AI agents with YAML configuration, MCP tool integration, and PostgreSQL persistence" requires-python = ">=3.11" dependencies = [ - "deepagents>=0.3.12", - "cryptography>=46.0.5", - "langchain-core>=1.2.22", + "deepagents>=0.6.10", + "cryptography>=48.0.1", + "langchain-core>=1.4.7", "pyasn1>=0.6.3", - "pyjwt>=2.12.0", - "langgraph>=1.0.10", + "pyjwt>=2.13.0", + "langgraph>=1.2.5", "requests>=2.33.0", "fastapi>=0.128.4", - "langchain-mcp-adapters>=0.1.0", - "langchain-openai>=1.1.7", + "langchain-mcp-adapters>=0.3.0", + "mcp>=1.27.0", + "langchain-openai>=1.1.15", "pydantic>=2.12.5", "pydantic-settings>=2.12.0", "pyyaml>=6.0.3", @@ -34,7 +35,15 @@ dependencies = [ "arize-phoenix-otel==0.15.0", "openinference-instrumentation-langchain==0.1.62", "arize-phoenix-client==2.3.0", - "tenacity>=8.0.0" + "tenacity>=8.0.0", + # --- Security: pin above fixed versions of known CVEs (trivy) --- + "aiohttp>=3.14.1", + "idna>=3.15", + "mako>=1.3.12", + "python-dotenv>=1.2.2", + "python-multipart>=0.0.30", + "starlette>=1.3.1", + "urllib3>=2.7.0", ] [dependency-groups] dev = [ diff --git a/src/alembic/env.py b/src/alembic/env.py index e367baf..0cf89d4 100644 --- a/src/alembic/env.py +++ b/src/alembic/env.py @@ -1,20 +1,22 @@ import asyncio -from logging.config import fileConfig from sqlalchemy import pool from sqlalchemy.ext.asyncio import async_engine_from_config from alembic import context +# Centralized logging configuration (replaces the legacy alembic.ini fileConfig). +from src.config import Settings + # Side-effect imports: register all models so Base.metadata is populated from src.infrastructure.database.models.agent_config import AgentConfigModel # noqa: F401 from src.infrastructure.database.models.base import Base from src.infrastructure.database.models.thread import MessageModel, ThreadModel # noqa: F401 +from src.infrastructure.logging import configure_logging config = context.config -if config.config_file_name is not None: - fileConfig(config.config_file_name) +configure_logging(Settings()) target_metadata = Base.metadata diff --git a/src/application/requests/chat.py b/src/application/requests/chat.py index 9307ed2..bdcd72a 100644 --- a/src/application/requests/chat.py +++ b/src/application/requests/chat.py @@ -3,6 +3,8 @@ from pydantic import BaseModel, Field, model_validator +from src.domain.logging.messages import LogMessage + logger = logging.getLogger(__name__) @@ -20,13 +22,13 @@ def validate_input(self): has_message = self.message is not None has_hitl = self.tool_call_id is not None if has_message == has_hitl: - logger.error("Provide either 'message' or HITL fields, not both") + logger.error(LogMessage.VALIDATION_MSG_AND_HITL_EXCLUSIVE) raise ValueError("Provide either 'message' or HITL fields (tool_call_id + action), not both.") if has_hitl and self.action is None: - logger.error("'action' is required for HITL decisions") + logger.error(LogMessage.VALIDATION_ACTION_REQUIRED) raise ValueError("'action' is required for HITL decisions.") if self.action == "edit" and self.edits is None: - logger.error("'edits' is required for action 'edit'") + logger.error(LogMessage.VALIDATION_EDITS_REQUIRED) raise ValueError("'edits' is required for action 'edit'.") return self diff --git a/src/application/requests/prompt.py b/src/application/requests/prompt.py index 220f9e7..68d656d 100644 --- a/src/application/requests/prompt.py +++ b/src/application/requests/prompt.py @@ -1,4 +1,5 @@ from enum import Enum + from pydantic import BaseModel, Field, field_validator, model_validator diff --git a/src/application/routes/agents.py b/src/application/routes/agents.py index 0841d3f..83289e7 100644 --- a/src/application/routes/agents.py +++ b/src/application/routes/agents.py @@ -2,7 +2,7 @@ import re from typing import Annotated -from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status +from fastapi import APIRouter, Depends, File, Form, UploadFile, status from src.application.use_cases.create_agent_config import CreateAgentConfigUseCase from src.application.use_cases.delete_agent_config import DeleteAgentConfigUseCase @@ -18,6 +18,9 @@ ) from src.domain.entities.agent_config import AgentConfig from src.domain.entities.agent_config_metadata import AgentConfigMetadata +from src.domain.errors.config import ConfigError +from src.domain.errors.messages import ErrorMessage +from src.domain.logging.messages import LogMessage logger = logging.getLogger(__name__) @@ -29,20 +32,19 @@ def _validate_agent_name(name: str) -> None: if not AGENT_NAME_PATTERN.match(name): - raise HTTPException( - status_code=400, - detail=f"Invalid agent name '{name}'. Must match pattern: alphanumeric, dots, hyphens, underscores, 2-100 chars.", + raise ConfigError( + ErrorMessage.INVALID_AGENT_NAME.format(name=name), ) async def _read_yaml_upload(file: UploadFile) -> str: data = await file.read() if len(data) > MAX_UPLOAD_SIZE: - raise HTTPException(status_code=400, detail=f"File too large. Maximum size is {MAX_UPLOAD_SIZE} bytes.") + raise ConfigError(ErrorMessage.FILE_TOO_LARGE.format(max_size=MAX_UPLOAD_SIZE)) try: return data.decode("utf-8") except UnicodeDecodeError as e: - raise HTTPException(status_code=400, detail="File must be valid UTF-8 encoded YAML.") from e + raise ConfigError(ErrorMessage.FILE_NOT_UTF8) from e @router.get("", response_model=list[AgentConfigMetadata]) @@ -51,7 +53,7 @@ async def list_agents( ) -> list[AgentConfigMetadata]: """List all agent configuration metadata.""" agents = await use_case.execute() - logger.info("Listed %d agent configs", len(agents)) + logger.info(LogMessage.AGENT_CONFIG_LISTED, len(agents)) return agents @@ -62,7 +64,7 @@ async def get_agent( ) -> AgentConfig: """Retrieve a single agent configuration by name.""" _validate_agent_name(agent_name) - logger.info("Getting agent config: %s", agent_name) + logger.info(LogMessage.AGENT_CONFIG_GET, agent_name) return await use_case.execute(name=agent_name) @@ -75,9 +77,9 @@ async def create_agent( """Create a new agent configuration from an uploaded YAML file.""" _validate_agent_name(agent_name) yaml_content = await _read_yaml_upload(file) - logger.info("Creating agent config: %s", agent_name) + logger.info(LogMessage.AGENT_CONFIG_CREATING, agent_name) result = await use_case.execute(name=agent_name, yaml_content=yaml_content) - logger.info("Agent config created: %s", agent_name) + logger.info(LogMessage.AGENT_CONFIG_CREATED, agent_name) return result @@ -90,9 +92,9 @@ async def update_agent( """Update an existing agent configuration from an uploaded YAML file.""" _validate_agent_name(agent_name) yaml_content = await _read_yaml_upload(file) - logger.info("Updating agent config: %s", agent_name) + logger.info(LogMessage.AGENT_CONFIG_UPDATING, agent_name) result = await use_case.execute(name=agent_name, yaml_content=yaml_content) - logger.info("Agent config updated: %s", agent_name) + logger.info(LogMessage.AGENT_CONFIG_UPDATED, agent_name) return result @@ -103,6 +105,6 @@ async def delete_agent( ) -> None: """Delete an agent configuration.""" _validate_agent_name(agent_name) - logger.info("Deleting agent config: %s", agent_name) + logger.info(LogMessage.AGENT_CONFIG_DELETING, agent_name) await use_case.execute(name=agent_name) - logger.info("Agent config deleted: %s", agent_name) + logger.info(LogMessage.AGENT_CONFIG_DELETED, agent_name) diff --git a/src/application/routes/chat.py b/src/application/routes/chat.py index b67de6c..3169a32 100644 --- a/src/application/routes/chat.py +++ b/src/application/routes/chat.py @@ -16,6 +16,7 @@ ) from src.domain.entities.message import Message from src.domain.entities.stream_event import StreamEvent, StreamEventType +from src.domain.logging.messages import LogMessage logger = logging.getLogger(__name__) @@ -28,9 +29,9 @@ async def send_message( body: ChatRequest, use_case: Annotated[SendMessageUseCase, Depends(get_send_message_use_case)], ) -> Message: - logger.info("[thread=%s] POST /chat - message=%s", thread_id, "HITL" if body.message is None else body.message[:80]) + logger.info(LogMessage.CHAT_RECEIVE, thread_id, "HITL" if body.message is None else body.message[:80]) result = await use_case.execute(thread_id, body) - logger.info("[thread=%s] Response status=%s content_len=%d", thread_id, result.status, len(result.content or "")) + logger.info(LogMessage.CHAT_RESPONSE, thread_id, result.status, len(result.content or "")) return result @@ -41,7 +42,7 @@ async def stream_message( use_case: Annotated[StreamMessageUseCase, Depends(get_stream_message_use_case)], get_thread: Annotated[GetThreadUseCase, Depends(get_get_thread_use_case)], ) -> EventSourceResponse: - logger.info("[thread=%s] POST /chat/stream - message=%s", thread_id, (body.message or "")[:80]) + logger.info(LogMessage.CHAT_STREAM_RECEIVE, thread_id, (body.message or "")[:80]) await get_thread.execute(thread_id) async def event_generator(): @@ -52,11 +53,11 @@ async def event_generator(): chunk_count += 1 yield {"data": event.model_dump_json()} yield {"data": "[DONE]"} - logger.info("[thread=%s] Stream complete, %d chunks", thread_id, chunk_count) + logger.info(LogMessage.CHAT_STREAM_COMPLETE, thread_id, chunk_count) except asyncio.CancelledError: raise except Exception as exc: - logger.exception("[thread=%s] Stream error after %d chunks", thread_id, chunk_count) + logger.exception(LogMessage.CHAT_STREAM_ERROR, thread_id, chunk_count) error_event = StreamEvent(type=StreamEventType.ERROR, data=str(exc)) yield {"data": error_event.model_dump_json()} diff --git a/src/application/routes/prompt.py b/src/application/routes/prompt.py index 85df774..f58ee38 100644 --- a/src/application/routes/prompt.py +++ b/src/application/routes/prompt.py @@ -1,7 +1,6 @@ import logging -from fastapi import APIRouter, Depends, HTTPException -from httpx import HTTPStatusError +from fastapi import APIRouter, Depends from src.application.requests.prompt import ( CreatePromptRequest, @@ -11,6 +10,7 @@ from src.application.use_cases.get_prompt import GetPromptUseCase from src.application.use_cases.update_prompt import UpdatePromptUseCase from src.dependencies import get_prompt_manager +from src.domain.logging.messages import LogMessage from src.domain.ports.prompt_manager import PromptManager logger = logging.getLogger(__name__) @@ -18,22 +18,6 @@ router = APIRouter(prefix="/prompts", tags=["prompts"]) -def _handle_http_error(e: Exception, identifier: str | None = None) -> HTTPException: - """Map exceptions to appropriate HTTP status codes.""" - if isinstance(e, ValueError) and "not found" in str(e).lower(): - return HTTPException(status_code=404, detail=str(e)) - if isinstance(e, HTTPStatusError): - if e.response.status_code == 404: - return HTTPException(status_code=404, detail=f"Prompt not found: {identifier}") - if e.response.status_code == 409: - return HTTPException(status_code=409, detail=f"Prompt already exists: {identifier}") - if e.response.status_code == 400: - return HTTPException(status_code=400, detail=str(e)) - if isinstance(e, ValueError): - return HTTPException(status_code=400, detail=str(e)) - return HTTPException(status_code=500, detail=str(e)) - - @router.post("/create", status_code=201) async def create_prompt( request: CreatePromptRequest, @@ -42,7 +26,7 @@ async def create_prompt( """Create a new prompt.""" use_case = CreatePromptUseCase(prompt_manager) try: - logger.info("Creating prompt: %s", request.identifier) + logger.info(LogMessage.PROMPT_CREATING, request.identifier) content_dicts = [msg.model_dump() for msg in request.content] prompt = await use_case.execute( identifier=request.identifier, @@ -52,10 +36,10 @@ async def create_prompt( tags=request.tags, metadata=request.metadata, ) - logger.info("Prompt created: %s", request.identifier) + logger.info(LogMessage.PROMPT_CREATED, request.identifier) return {"status": "success", "prompt": prompt} except Exception: - logger.exception("Error creating prompt '%s'", request.identifier) + logger.exception(LogMessage.PROMPT_CREATE_ERROR, request.identifier) raise @@ -76,7 +60,7 @@ async def get_prompt( ) return {"status": "success", "prompt": prompt} except Exception: - logger.exception("Error getting prompt '%s'", identifier) + logger.exception(LogMessage.PROMPT_GET_ERROR, identifier) raise @@ -99,5 +83,5 @@ async def update_prompt( ) return {"status": "success", "prompt": prompt} except Exception: - logger.exception("Error updating prompt '%s'", identifier) + logger.exception(LogMessage.PROMPT_UPDATE_ERROR, identifier) raise diff --git a/src/application/routes/threads.py b/src/application/routes/threads.py index 7cff719..d0ced4e 100644 --- a/src/application/routes/threads.py +++ b/src/application/routes/threads.py @@ -17,6 +17,7 @@ get_list_threads_use_case, ) from src.domain.entities.thread import Thread +from src.domain.logging.messages import LogMessage logger = logging.getLogger(__name__) @@ -28,9 +29,9 @@ async def create_thread( body: CreateThreadRequest, use_case: Annotated[CreateThreadUseCase, Depends(get_create_thread_use_case)], ) -> Thread: - logger.info("Creating thread for agent=%s", body.agent_name) + logger.info(LogMessage.THREAD_CREATING, body.agent_name) thread = await use_case.execute(body.agent_name) - logger.info("Thread created: id=%s agent=%s", thread.id, thread.agent_name) + logger.info(LogMessage.THREAD_CREATED, thread.id, thread.agent_name) return thread @@ -39,7 +40,7 @@ async def list_threads( use_case: Annotated[ListThreadsUseCase, Depends(get_list_threads_use_case)], ) -> list[Thread]: threads = await use_case.execute() - logger.info("Listed %d threads", len(threads)) + logger.info(LogMessage.THREAD_LISTED, len(threads)) return threads @@ -48,7 +49,7 @@ async def get_thread( thread_id: str, use_case: Annotated[GetThreadUseCase, Depends(get_get_thread_use_case)], ) -> Thread: - logger.info("Getting thread=%s", thread_id) + logger.info(LogMessage.THREAD_GETTING, thread_id) return await use_case.execute(thread_id) @@ -57,7 +58,7 @@ async def delete_thread( thread_id: str, use_case: Annotated[DeleteThreadUseCase, Depends(get_delete_thread_use_case)], ) -> None: - logger.info("Deleting thread=%s", thread_id) + logger.info(LogMessage.THREAD_DELETING, thread_id) await use_case.execute(thread_id) @@ -67,5 +68,5 @@ async def list_messages( use_case: Annotated[GetThreadUseCase, Depends(get_get_thread_use_case)], ) -> list: thread = await use_case.execute(thread_id) - logger.info("[thread=%s] Listed %d messages", thread_id, len(thread.messages)) + logger.info(LogMessage.THREAD_MESSAGES_LISTED, thread_id, len(thread.messages)) return thread.messages diff --git a/src/application/routes/websocket.py b/src/application/routes/websocket.py index 9076f4b..11d47b9 100644 --- a/src/application/routes/websocket.py +++ b/src/application/routes/websocket.py @@ -6,6 +6,7 @@ from src.application.use_cases.stream_message import StreamMessageUseCase from src.dependencies import get_stream_message_use_case from src.domain.entities.stream_event import StreamEvent, StreamEventType +from src.domain.logging.messages import LogMessage logger = logging.getLogger(__name__) @@ -19,18 +20,18 @@ async def websocket_chat( use_case: StreamMessageUseCase = Depends(get_stream_message_use_case), ) -> None: await websocket.accept() - logger.info("[thread=%s] WebSocket connected", thread_id) + logger.info(LogMessage.WS_CONNECTED, thread_id) try: while True: data = await websocket.receive_text() try: payload = json.loads(data) except json.JSONDecodeError: - logger.exception("[thread=%s] Invalid JSON received: %s", thread_id, data[:200]) + logger.exception(LogMessage.WS_INVALID_JSON, thread_id, data[:200]) await websocket.send_text(json.dumps({"error": "Invalid JSON"})) continue message = payload.get("message", "") - logger.info("[thread=%s] WS message received: %s", thread_id, message[:80]) + logger.info(LogMessage.WS_MESSAGE_RECEIVED, thread_id, message[:80]) chunk_count = 0 try: async for event in use_case.execute(thread_id, message): @@ -38,12 +39,12 @@ async def websocket_chat( chunk_count += 1 await websocket.send_text(event.model_dump_json()) await websocket.send_text("[END]") - logger.info("[thread=%s] WS stream complete, %d chunks", thread_id, chunk_count) + logger.info(LogMessage.WS_STREAM_COMPLETE, thread_id, chunk_count) except Exception as exc: - logger.exception("[thread=%s] WS stream error after %d chunks", thread_id, chunk_count) + logger.exception(LogMessage.WS_STREAM_ERROR, thread_id, chunk_count) error_event = StreamEvent(type=StreamEventType.ERROR, data=str(exc)) await websocket.send_text(error_event.model_dump_json()) except WebSocketDisconnect: - logger.info("[thread=%s] WebSocket disconnected", thread_id) + logger.info(LogMessage.WS_DISCONNECTED, thread_id) except Exception: - logger.exception("[thread=%s] WebSocket unexpected error", thread_id) + logger.exception(LogMessage.WS_UNEXPECTED_ERROR, thread_id) diff --git a/src/application/use_cases/create_agent_config.py b/src/application/use_cases/create_agent_config.py index 0b4b845..1cc6186 100644 --- a/src/application/use_cases/create_agent_config.py +++ b/src/application/use_cases/create_agent_config.py @@ -3,7 +3,10 @@ from src.domain.entities.agent_config import AgentConfig from src.domain.entities.agent_config_metadata import AgentConfigMetadata -from src.domain.exceptions import AgentConfigAlreadyExistsError, ConfigError +from src.domain.errors.agent import AgentConfigAlreadyExistsError +from src.domain.errors.config import ConfigError +from src.domain.errors.messages import ErrorMessage +from src.domain.logging.messages import LogMessage from src.domain.ports.agent_config_loader import AgentConfigLoader from src.domain.ports.agent_config_repository import AgentConfigRepository from src.domain.ports.agent_config_store import AgentConfigStore @@ -41,10 +44,12 @@ async def execute(self, name: str, yaml_content: str) -> AgentConfig: config = self._config_loader.load_from_string(yaml_content) if config.name != name: - raise ConfigError(f"Agent name in YAML '{config.name}' does not match provided name '{name}'") + raise ConfigError( + ErrorMessage.AGENT_NAME_MISMATCH.format(yaml_name=config.name, name=name) + ) if await self._config_repository.exists(name): - raise AgentConfigAlreadyExistsError(f"Agent config already exists: {name}") + raise AgentConfigAlreadyExistsError(ErrorMessage.AGENT_CONFIG_ALREADY_EXISTS.format(name=name)) await self._config_store.put(name, yaml_content) @@ -58,5 +63,5 @@ async def execute(self, name: str, yaml_content: str) -> AgentConfig: ) await self._config_repository.save(metadata) - logger.info("Created agent config '%s'", name) + logger.info(LogMessage.AGENT_CONFIG_CREATED_UC, name) return config diff --git a/src/application/use_cases/create_prompt.py b/src/application/use_cases/create_prompt.py index 082b32a..43cd67a 100644 --- a/src/application/use_cases/create_prompt.py +++ b/src/application/use_cases/create_prompt.py @@ -1,6 +1,7 @@ import logging from src.domain.entities.prompt import PromptVersion +from src.domain.logging.messages import LogMessage from src.domain.ports.prompt_manager import PromptManager logger = logging.getLogger(__name__) @@ -22,7 +23,7 @@ async def execute( metadata: dict | None = None, ) -> PromptVersion: """Create a new prompt.""" - logger.info(f"Creating prompt: {identifier}") + logger.info(LogMessage.PROMPT_CREATING, identifier) prompt = await self._prompt_manager.create_prompt( identifier=identifier, content=content, @@ -31,5 +32,5 @@ async def execute( tags=tags, metadata=metadata, ) - logger.info(f"Prompt created successfully: {identifier}") + logger.info(LogMessage.PROMPT_CREATED_SUCCESS, identifier) return prompt diff --git a/src/application/use_cases/delete_agent_config.py b/src/application/use_cases/delete_agent_config.py index 635e4c0..e79d11f 100644 --- a/src/application/use_cases/delete_agent_config.py +++ b/src/application/use_cases/delete_agent_config.py @@ -1,5 +1,6 @@ import logging +from src.domain.logging.messages import LogMessage from src.domain.ports.agent_config_repository import AgentConfigRepository from src.domain.ports.agent_config_store import AgentConfigStore from src.domain.ports.agent_registry import AgentRegistry @@ -35,4 +36,4 @@ async def execute(self, name: str) -> None: await self._config_repository.delete(name) await self._agent_registry.invalidate(name) - logger.info("Deleted agent config '%s'", name) + logger.info(LogMessage.AGENT_CONFIG_DELETED_UC, name) diff --git a/src/application/use_cases/get_agent_config.py b/src/application/use_cases/get_agent_config.py index 38d26d4..d84da1b 100644 --- a/src/application/use_cases/get_agent_config.py +++ b/src/application/use_cases/get_agent_config.py @@ -1,6 +1,7 @@ import logging from src.domain.entities.agent_config import AgentConfig +from src.domain.logging.messages import LogMessage from src.domain.ports.agent_config_loader import AgentConfigLoader from src.domain.ports.agent_config_store import AgentConfigStore @@ -33,5 +34,5 @@ async def execute(self, name: str) -> AgentConfig: """ yaml_content = await self._config_store.get(name) config = self._config_loader.load_from_string(yaml_content) - logger.info("Loaded agent config '%s' from store", name) + logger.info(LogMessage.AGENT_CONFIG_LOADED_FROM_STORE, name) return config diff --git a/src/application/use_cases/get_prompt.py b/src/application/use_cases/get_prompt.py index 2c2671c..e0ba5c8 100644 --- a/src/application/use_cases/get_prompt.py +++ b/src/application/use_cases/get_prompt.py @@ -1,6 +1,7 @@ import logging from src.domain.entities.prompt import Prompt +from src.domain.logging.messages import LogMessage from src.domain.ports.prompt_manager import PromptManager logger = logging.getLogger(__name__) @@ -19,7 +20,7 @@ async def execute( tag: str | None = None, ) -> Prompt: """Get a prompt.""" - logger.info(f"Retrieving prompt: {identifier}") + logger.info(LogMessage.PROMPT_RETRIEVING, identifier) prompt = await self._prompt_manager.get_prompt( identifier=identifier, version_id=version_id, @@ -29,7 +30,7 @@ async def execute( async def execute_get_prompt_content(self, identifier: str, version_id: str | None = None, tag: str | None = None) -> dict: """Get the content of a prompt.""" - logger.info(f"Retrieving prompt content: {identifier}") + logger.info(LogMessage.PROMPT_RETRIEVING_CONTENT, identifier) content = await self._prompt_manager.get_prompt_content( identifier=identifier, version_id=version_id, diff --git a/src/application/use_cases/list_agent_configs.py b/src/application/use_cases/list_agent_configs.py index 148353a..215db70 100644 --- a/src/application/use_cases/list_agent_configs.py +++ b/src/application/use_cases/list_agent_configs.py @@ -1,6 +1,7 @@ import logging from src.domain.entities.agent_config_metadata import AgentConfigMetadata +from src.domain.logging.messages import LogMessage from src.domain.ports.agent_config_repository import AgentConfigRepository logger = logging.getLogger(__name__) @@ -19,5 +20,5 @@ async def execute(self) -> list[AgentConfigMetadata]: List of AgentConfigMetadata. """ result = await self._config_repository.list_all() - logger.info("Listed %d agent configs from repository", len(result)) + logger.info(LogMessage.AGENT_CONFIG_LISTED_FROM_REPO, len(result)) return result diff --git a/src/application/use_cases/send_message.py b/src/application/use_cases/send_message.py index 33d8eb3..fa5664b 100644 --- a/src/application/use_cases/send_message.py +++ b/src/application/use_cases/send_message.py @@ -3,6 +3,9 @@ from src.application.requests.chat import ChatRequest from src.domain.entities.message import Message, MessageRole +from src.domain.errors.hitl import InvalidHitlActionError +from src.domain.errors.messages import ErrorMessage +from src.domain.logging.messages import LogMessage from src.domain.ports.agent_registry import AgentRegistry from src.domain.ports.thread_repository import ThreadRepository @@ -43,17 +46,17 @@ async def execute(self, thread_id: str, request: ChatRequest) -> Message: runner = await self._registry.get_runner(thread.agent_name) if request.message is not None: - logger.info("[thread=%s][agent=%s] Sending human message", thread_id, thread.agent_name) + logger.info(LogMessage.CHAT_SENDING_HUMAN, thread_id, thread.agent_name) if not self._is_duplicate_human_message(thread.messages, request.message): human_msg = Message(role=MessageRole.HUMAN, content=request.message) await self._threads.add_message(thread_id, human_msg) else: - logger.info("[thread=%s] Skipping duplicate HUMAN message", thread_id) + logger.info(LogMessage.CHAT_SKIP_DUPLICATE_HUMAN, thread_id) start = time.monotonic() response = await runner.invoke(thread_id, request.message) elapsed = time.monotonic() - start logger.info( - "[thread=%s][agent=%s] Invoke elapsed=%.2fs, status=%s, len=%d", + LogMessage.CHAT_INVOKE_COMPLETE, thread_id, thread.agent_name, elapsed, @@ -62,7 +65,7 @@ async def execute(self, thread_id: str, request: ChatRequest) -> Message: ) else: logger.info( - "[thread=%s][agent=%s] HITL action=%s tool_call_id=%s", + LogMessage.CHAT_HITL_RECEIVED, thread_id, thread.agent_name, request.action, @@ -77,10 +80,10 @@ async def execute(self, thread_id: str, request: ChatRequest) -> Message: case "edit": response = await runner.edit_hitl(thread_id, request.tool_call_id, request.edits) case _: - raise ValueError(f"Unsupported HITL action: {request.action}") + raise InvalidHitlActionError(ErrorMessage.INVALID_HITL_ACTION.format(action=request.action)) elapsed = time.monotonic() - start logger.info( - "[thread=%s][agent=%s] HITL elapsed=%.2fs, status=%s", + LogMessage.CHAT_HITL_COMPLETE, thread_id, thread.agent_name, elapsed, diff --git a/src/application/use_cases/stream_message.py b/src/application/use_cases/stream_message.py index 2c7b58e..d31b1c2 100644 --- a/src/application/use_cases/stream_message.py +++ b/src/application/use_cases/stream_message.py @@ -1,21 +1,17 @@ import json import logging -import sys import time from collections.abc import AsyncGenerator from src.domain.entities.message import Message, MessageRole from src.domain.entities.stream_event import StreamEvent, StreamEventType +from src.domain.errors.messages import ErrorMessage +from src.domain.errors.storage import StorageError +from src.domain.logging.messages import LogMessage from src.domain.ports.agent_registry import AgentRegistry from src.domain.ports.thread_repository import ThreadRepository logger = logging.getLogger(__name__) -if not logger.handlers: - _handler = logging.StreamHandler(sys.stdout) - _handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")) - logger.addHandler(_handler) - logger.setLevel(logging.INFO) - logger.propagate = False class StreamMessageUseCase: @@ -53,10 +49,10 @@ async def execute(self, thread_id: str, message: str) -> AsyncGenerator[StreamEv human_msg = Message(role=MessageRole.HUMAN, content=message) await self._threads.add_message(thread_id, human_msg) else: - logger.info("[thread=%s] Skipping duplicate HUMAN message", thread_id) + logger.info(LogMessage.CHAT_SKIP_DUPLICATE_HUMAN, thread_id) runner = await self._registry.get_runner(thread.agent_name) start = time.monotonic() - logger.info("[thread=%s][agent=%s] Stream started", thread_id, thread.agent_name) + logger.info(LogMessage.CHAT_STREAM_STARTED, thread_id, thread.agent_name) chunk_count = 0 final_message = None try: @@ -74,7 +70,7 @@ async def execute(self, thread_id: str, message: str) -> AsyncGenerator[StreamEv yield event except Exception: logger.exception( - "[thread=%s][agent=%s] Stream error after %d chunks", thread_id, thread.agent_name, chunk_count + LogMessage.CHAT_STREAM_ERROR_UC, thread_id, thread.agent_name, chunk_count ) raise elapsed = time.monotonic() - start @@ -82,7 +78,7 @@ async def execute(self, thread_id: str, message: str) -> AsyncGenerator[StreamEv try: await self._threads.add_message(thread_id, final_message) logger.info( - "[thread=%s][agent=%s] Stream complete, %d chunks, elapsed=%.2fs, message=persisted", + LogMessage.CHAT_STREAM_COMPLETE_PERSISTED, thread_id, thread.agent_name, chunk_count, @@ -90,6 +86,6 @@ async def execute(self, thread_id: str, message: str) -> AsyncGenerator[StreamEv ) except Exception as exc: logger.exception( - "[thread=%s][agent=%s] Failed to persist AI message after stream", thread_id, thread.agent_name + LogMessage.CHAT_STREAM_PERSIST_FAILED, thread_id, thread.agent_name ) - raise RuntimeError(f"Failed to persist AI message after stream: {exc}") from exc + raise StorageError(ErrorMessage.STORAGE_FAILED_PERSIST_STREAM.format(error=exc)) from exc diff --git a/src/application/use_cases/thread_management.py b/src/application/use_cases/thread_management.py index 04416ba..d9d933d 100644 --- a/src/application/use_cases/thread_management.py +++ b/src/application/use_cases/thread_management.py @@ -1,7 +1,9 @@ import logging from src.domain.entities.thread import Thread -from src.domain.exceptions import AgentNotFoundError +from src.domain.errors.agent import AgentNotFoundError +from src.domain.errors.messages import ErrorMessage +from src.domain.logging.messages import LogMessage from src.domain.ports.agent_registry import AgentRegistry from src.domain.ports.thread_repository import ThreadRepository @@ -15,9 +17,9 @@ def __init__(self, threads: ThreadRepository, registry: AgentRegistry): async def execute(self, agent_name: str) -> Thread: if agent_name not in await self._registry.list_agents(): - raise AgentNotFoundError(f"Agent not found: {agent_name}") + raise AgentNotFoundError(ErrorMessage.AGENT_NOT_FOUND.format(name=agent_name)) thread = await self._threads.create(agent_name) - logger.info("Thread created: id=%s agent=%s", thread.id, agent_name) + logger.info(LogMessage.THREAD_CREATED, thread.id, agent_name) return thread diff --git a/src/application/use_cases/update_agent_config.py b/src/application/use_cases/update_agent_config.py index 3048241..46ce850 100644 --- a/src/application/use_cases/update_agent_config.py +++ b/src/application/use_cases/update_agent_config.py @@ -2,7 +2,9 @@ from datetime import UTC, datetime from src.domain.entities.agent_config import AgentConfig -from src.domain.exceptions import ConfigError +from src.domain.errors.config import ConfigError +from src.domain.errors.messages import ErrorMessage +from src.domain.logging.messages import LogMessage from src.domain.ports.agent_config_loader import AgentConfigLoader from src.domain.ports.agent_config_repository import AgentConfigRepository from src.domain.ports.agent_config_store import AgentConfigStore @@ -45,7 +47,9 @@ async def execute(self, name: str, yaml_content: str) -> AgentConfig: config = self._config_loader.load_from_string(yaml_content) if config.name != name: - raise ConfigError(f"Agent name in YAML '{config.name}' does not match URL name '{name}'") + raise ConfigError( + ErrorMessage.AGENT_NAME_MISMATCH_URL.format(yaml_name=config.name, name=name) + ) await self._config_store.put(name, yaml_content) @@ -57,5 +61,5 @@ async def execute(self, name: str, yaml_content: str) -> AgentConfig: await self._agent_registry.invalidate(name) - logger.info("Updated agent config '%s'", name) + logger.info(LogMessage.AGENT_CONFIG_UPDATED_UC, name) return config diff --git a/src/application/use_cases/update_prompt.py b/src/application/use_cases/update_prompt.py index fc1e594..bb82f25 100644 --- a/src/application/use_cases/update_prompt.py +++ b/src/application/use_cases/update_prompt.py @@ -2,6 +2,7 @@ from phoenix.client.resources.prompts import PromptVersion +from src.domain.logging.messages import LogMessage from src.domain.ports.prompt_manager import PromptManager logger = logging.getLogger(__name__) @@ -22,7 +23,7 @@ async def execute( metadata: dict | None = None, ) -> PromptVersion: """Update a prompt.""" - logger.info(f"Updating prompt: {identifier}") + logger.info(LogMessage.PROMPT_UPDATING, identifier) prompt = await self._prompt_manager.update_prompt( identifier=identifier, content=content, @@ -30,5 +31,5 @@ async def execute( description=description, metadata=metadata, ) - logger.info(f"Prompt updated successfully: {identifier}") + logger.info(LogMessage.PROMPT_UPDATED_SUCCESS, identifier) return prompt diff --git a/src/config.py b/src/config.py index 5071694..f14dd98 100644 --- a/src/config.py +++ b/src/config.py @@ -21,9 +21,16 @@ class Settings(BaseSettings): openai_api_key: str | None = None host: str = "0.0.0.0" port: int = 8000 + log_level: str = "info" uvicorn_log_level: str = "info" allowed_origins: list[str] = ["http://localhost:8080"] tracing: TracingSettings = TracingSettings() + # Agent execution timeouts (seconds). The per-tool timeout isolates a hung + # MCP tool as a recoverable ToolMessage error (agent continues). The graph + # idle/invoke timeouts are backstops that only fire on a total stall. + mcp_tool_timeout: float = 60.0 + agent_stream_idle_timeout: float = 120.0 + agent_invoke_timeout: float = 120.0 minio_endpoint: str = "localhost:9040" minio_access_key: str = "minioadmin" diff --git a/src/dependencies.py b/src/dependencies.py index bef9a14..26be5d7 100644 --- a/src/dependencies.py +++ b/src/dependencies.py @@ -19,7 +19,9 @@ ) from src.application.use_cases.update_agent_config import UpdateAgentConfigUseCase from src.config import Settings -from src.domain.exceptions import StorageError +from src.domain.errors.messages import ErrorMessage +from src.domain.errors.storage import StorageError +from src.domain.logging.messages import LogMessage from src.domain.ports.agent_registry import AgentRegistry from src.domain.ports.prompt_manager import PromptManager from src.domain.ports.thread_repository import ThreadRepository @@ -28,8 +30,8 @@ from src.infrastructure.persistent_registry.adapter import PersistentAgentRegistry from src.infrastructure.postgres_repository.adapter import PostgresAgentConfigRepository from src.infrastructure.postgres_thread.adapter import PostgresThreadRepository -from src.infrastructure.tracing.noop_adapter import NoopTracingProvider from src.infrastructure.prompt_management.phoenix_prompt_adapter import PhoenixPromptManagerProvider +from src.infrastructure.tracing.noop_adapter import NoopTracingProvider from src.infrastructure.yaml_config.adapter import YamlAgentConfigLoader logger = logging.getLogger(__name__) @@ -55,7 +57,7 @@ def _create_tracing_provider(settings: Settings): if tracing.enabled and tracing.provider == "langfuse": from src.infrastructure.tracing.langfuse_adapter import LangfuseTracingProvider - logger.info("Initializing Langfuse tracing provider (host=%s)", tracing.langfuse_host) + logger.info(LogMessage.TRACING_LANGFUSE_INIT, tracing.langfuse_host) return LangfuseTracingProvider( public_key=tracing.langfuse_public_key or "", secret_key=tracing.langfuse_secret_key or "", @@ -65,14 +67,14 @@ def _create_tracing_provider(settings: Settings): if tracing.enabled and tracing.provider == "phoenix": from src.infrastructure.tracing.phoenix_adapter import PhoenixTracingProvider - logger.info("Initializing Phoenix tracing provider (endpoint=%s)", tracing.phoenix_collector_endpoint) + logger.info(LogMessage.TRACING_PHOENIX_INIT, tracing.phoenix_collector_endpoint) return PhoenixTracingProvider( endpoint=tracing.phoenix_collector_endpoint, api_key=tracing.phoenix_api_key, project_name=tracing.project_name, ) - logger.info("Tracing disabled, using NoopTracingProvider") + logger.info(LogMessage.TRACING_DISABLED) return NoopTracingProvider() @@ -88,7 +90,7 @@ def get_prompt_manager() -> PromptManager: # ============= ADAPTERS ============= agent_config_loader = YamlAgentConfigLoader() -mcp_tool_loader = LangchainMcpToolLoader() +mcp_tool_loader = LangchainMcpToolLoader(tool_timeout=settings.mcp_tool_timeout) tracing_provider = _create_tracing_provider(settings) # ============= PERSISTENCE (initialized at startup) ============= @@ -107,7 +109,7 @@ async def init_persistence() -> None: """ global _async_engine, _minio_store, _pg_repository, agent_registry, thread_repository - logger.info("Initializing persistence layer") + logger.info(LogMessage.PERSISTENCE_INITIALIZING) _async_engine = create_async_engine( settings.database_url, @@ -116,11 +118,11 @@ async def init_persistence() -> None: max_overflow=20, pool_pre_ping=True, ) - logger.info("SQLAlchemy async engine created (pool: AsyncAdaptedQueuePool, size=20, max_overflow=20)") + logger.info(LogMessage.SQLALCHEMY_ENGINE_CREATED) _pg_repository = PostgresAgentConfigRepository(engine=_async_engine) thread_repository = PostgresThreadRepository(engine=_async_engine) - logger.info("PostgreSQL repositories initialized") + logger.info(LogMessage.POSTGRES_REPOS_INITIALIZED) minio_client = Minio( settings.minio_endpoint, @@ -130,7 +132,7 @@ async def init_persistence() -> None: ) _minio_store = MinioAgentConfigStore(client=minio_client, bucket=settings.minio_bucket) await _minio_store.ensure_bucket() - logger.info("MinIO store initialized (bucket=%s)", settings.minio_bucket) + logger.info(LogMessage.MINIO_STORE_INITIALIZED, settings.minio_bucket) agent_registry = PersistentAgentRegistry( config_loader=agent_config_loader, @@ -139,9 +141,11 @@ async def init_persistence() -> None: mcp_tool_loader=mcp_tool_loader, tracing_provider=tracing_provider, prompt_manager=get_prompt_manager(), + stream_idle_timeout=settings.agent_stream_idle_timeout, + invoke_timeout=settings.agent_invoke_timeout, ) - logger.info("Persistence layer initialized, agent_registry set to PersistentAgentRegistry") + logger.info(LogMessage.PERSISTENCE_REGISTRY_SET) async def close_persistence() -> None: @@ -151,14 +155,14 @@ async def close_persistence() -> None: """ if agent_registry and isinstance(agent_registry, PersistentAgentRegistry): await agent_registry.close() - logger.info("Persistent registry closed") + logger.info(LogMessage.PERSISTENT_REGISTRY_CLOSED) if _async_engine: await _async_engine.dispose() - logger.info("SQLAlchemy engine disposed") + logger.info(LogMessage.SQLALCHEMY_ENGINE_DISPOSED) -logger.info("Dependencies initialized") +logger.info(LogMessage.DEPENDENCIES_INITIALIZED) # ============= USE CASE PROVIDERS ============= @@ -167,14 +171,14 @@ async def close_persistence() -> None: def _require_thread_repository() -> ThreadRepository: """Return thread repository or raise StorageError if not initialized.""" if thread_repository is None: - raise StorageError("Thread repository not initialized. Check PostgreSQL connectivity.") + raise StorageError(ErrorMessage.STORAGE_REPO_NOT_INITIALIZED) return thread_repository def _require_agent_registry() -> AgentRegistry: """Return agent registry or raise StorageError if not initialized.""" if agent_registry is None: - raise StorageError("Agent registry not initialized. Check MinIO/PostgreSQL connectivity.") + raise StorageError(ErrorMessage.STORAGE_REGISTRY_NOT_INITIALIZED) return agent_registry @@ -216,7 +220,7 @@ def get_load_agent_config_use_case() -> LoadAgentConfigUseCase: def _require_persistence() -> tuple[MinioAgentConfigStore, PostgresAgentConfigRepository]: """Return persistence adapters or raise StorageError if not initialized.""" if _minio_store is None or _pg_repository is None: - raise StorageError("Persistence layer not initialized. Check MinIO/PostgreSQL connectivity.") + raise StorageError(ErrorMessage.STORAGE_PERSISTENCE_NOT_INITIALIZED) return _minio_store, _pg_repository diff --git a/src/domain/entities/agent_config.py b/src/domain/entities/agent_config.py index 4b09a67..f3a7714 100644 --- a/src/domain/entities/agent_config.py +++ b/src/domain/entities/agent_config.py @@ -5,6 +5,7 @@ from pydantic import BaseModel, Field, model_validator from src.domain.entities.mcp_server_config import McpServerConfig +from src.domain.logging.messages import LogMessage logger = logging.getLogger(__name__) @@ -67,6 +68,6 @@ class AgentConfig(BaseModel, frozen=True): @model_validator(mode="after") def check_prompt_exclusivity(self) -> Self: if self.system_prompt and self.system_prompt_file: - logger.error("system_prompt and system_prompt_file are mutually exclusive") + logger.error(LogMessage.VALIDATION_PROMPTS_MUTUALLY_EXCLUSIVE) raise ValueError("system_prompt and system_prompt_file are mutually exclusive") return self diff --git a/src/domain/entities/mcp_server_config.py b/src/domain/entities/mcp_server_config.py index 8861177..bf7c2aa 100644 --- a/src/domain/entities/mcp_server_config.py +++ b/src/domain/entities/mcp_server_config.py @@ -4,6 +4,8 @@ from pydantic import BaseModel, Field, model_validator +from src.domain.logging.messages import LogMessage + logger = logging.getLogger(__name__) @@ -27,9 +29,9 @@ class McpServerConfig(BaseModel, frozen=True): @model_validator(mode="after") def validate_transport_fields(self) -> Self: if self.transport == McpTransportType.STDIO and not self.command: - logger.error("'command' is required for stdio transport") + logger.error(LogMessage.VALIDATION_COMMAND_REQUIRED) raise ValueError("'command' is required for stdio transport") if self.transport == McpTransportType.HTTP and not self.url: - logger.error("'url' is required for http transport") + logger.error(LogMessage.VALIDATION_URL_REQUIRED) raise ValueError("'url' is required for http transport") return self diff --git a/src/domain/errors/agent.py b/src/domain/errors/agent.py new file mode 100644 index 0000000..b611e9e --- /dev/null +++ b/src/domain/errors/agent.py @@ -0,0 +1,22 @@ +"""Agent-related domain errors.""" + +from src.domain.errors.base import DomainError +from src.domain.errors.codes import ErrorCode + + +class AgentError(DomainError): + """Agent execution error (LLM failure, graph error).""" + + status_code = ErrorCode.BAD_GATEWAY + + +class AgentNotFoundError(AgentError): + """Agent not found.""" + + status_code = ErrorCode.NOT_FOUND + + +class AgentConfigAlreadyExistsError(DomainError): + """Agent configuration already exists.""" + + status_code = ErrorCode.CONFLICT diff --git a/src/domain/errors/base.py b/src/domain/errors/base.py new file mode 100644 index 0000000..f8a009a --- /dev/null +++ b/src/domain/errors/base.py @@ -0,0 +1,23 @@ +"""Base domain exception. + +All application errors inherit from :class:`DomainError`. Each subclass +declares its own ``status_code`` (an :class:`~src.domain.errors.codes.ErrorCode` +constant) so that a single generic HTTP handler can translate any domain error +into a response without a separate mapping registry. + +Attributes: + status_code: HTTP status code mapped to this error (see ErrorCode). + detail: Human-readable error description. +""" + +from src.domain.errors.codes import ErrorCode + + +class DomainError(Exception): + """Base domain exception. All application errors inherit from it.""" + + status_code: int = ErrorCode.INTERNAL_SERVER_ERROR + + def __init__(self, detail: str) -> None: + self.detail = detail + super().__init__(self.detail) diff --git a/src/domain/errors/codes.py b/src/domain/errors/codes.py new file mode 100644 index 0000000..489d965 --- /dev/null +++ b/src/domain/errors/codes.py @@ -0,0 +1,22 @@ +"""Centralized HTTP status code constants for domain errors. + +Each domain exception declares one of these codes as a ``status_code`` class +attribute, so each registered HTTP handler can read ``exc.status_code`` to build +the response without a separate mapping registry. +""" + +from enum import IntEnum + + +class ErrorCode(IntEnum): + """HTTP status codes used by domain error handlers.""" + + BAD_REQUEST = 400 + FORBIDDEN = 403 + NOT_FOUND = 404 + CONFLICT = 409 + GONE = 410 + UNPROCESSABLE_ENTITY = 422 + INTERNAL_SERVER_ERROR = 500 + BAD_GATEWAY = 502 + SERVICE_UNAVAILABLE = 503 diff --git a/src/domain/errors/config.py b/src/domain/errors/config.py new file mode 100644 index 0000000..250a59e --- /dev/null +++ b/src/domain/errors/config.py @@ -0,0 +1,27 @@ +"""Configuration-related domain errors.""" + +from src.domain.errors.base import DomainError +from src.domain.errors.codes import ErrorCode + + +class ConfigError(DomainError): + """Configuration error (invalid input, malformed file, bad request body).""" + + status_code = ErrorCode.BAD_REQUEST + + +class ConfigNotFoundError(ConfigError): + """Configuration file or resource not found.""" + + status_code = ErrorCode.NOT_FOUND + + +class ConfigValidationError(ConfigError): + """Schema validation error carrying structured error details.""" + + status_code = ErrorCode.UNPROCESSABLE_ENTITY + + def __init__(self, errors: list[dict]) -> None: + self.errors = errors + messages = [f" - {e.get('loc', '?')}: {e.get('msg', '?')}" for e in errors] + super().__init__("Validation errors:\n" + "\n".join(messages)) diff --git a/src/domain/errors/hitl.py b/src/domain/errors/hitl.py new file mode 100644 index 0000000..ae13602 --- /dev/null +++ b/src/domain/errors/hitl.py @@ -0,0 +1,10 @@ +"""Human-in-the-loop domain errors.""" + +from src.domain.errors.base import DomainError +from src.domain.errors.codes import ErrorCode + + +class InvalidHitlActionError(DomainError): + """Invalid HITL action submitted for an interrupted tool call.""" + + status_code = ErrorCode.BAD_REQUEST diff --git a/src/domain/errors/mcp.py b/src/domain/errors/mcp.py new file mode 100644 index 0000000..fa1582e --- /dev/null +++ b/src/domain/errors/mcp.py @@ -0,0 +1,22 @@ +"""MCP (Model Context Protocol) domain errors.""" + +from src.domain.errors.base import DomainError +from src.domain.errors.codes import ErrorCode + + +class McpError(DomainError): + """Base error for MCP operations.""" + + status_code = ErrorCode.BAD_GATEWAY + + +class McpConnectionError(McpError): + """Error connecting to an MCP server.""" + + status_code = ErrorCode.BAD_GATEWAY + + +class McpToolLoadError(McpError): + """Error loading MCP tools.""" + + status_code = ErrorCode.BAD_GATEWAY diff --git a/src/domain/errors/messages.py b/src/domain/errors/messages.py new file mode 100644 index 0000000..4a0ab2c --- /dev/null +++ b/src/domain/errors/messages.py @@ -0,0 +1,95 @@ +"""Centralized error message templates. + +The message text raised with every domain error is declared here so error +wording is discoverable, consistent and never duplicated. Callers format the +template with the runtime values:: + + raise ConfigError(ErrorMessage.INVALID_AGENT_NAME.format(name)) +""" + +from enum import StrEnum +from string import Template + + +class ErrorMessage(StrEnum): + """Catalog of centralized error message templates (``str.format`` style).""" + + # --- Configuration --- + INVALID_AGENT_NAME = ( + "Invalid agent name '{name}'. Must match pattern: alphanumeric, " + "dots, hyphens, underscores, 2-100 chars." + ) + FILE_TOO_LARGE = "File too large. Maximum size is {max_size} bytes." + FILE_NOT_UTF8 = "File must be valid UTF-8 encoded YAML." + AGENT_NAME_MISMATCH = "Agent name in YAML '{yaml_name}' does not match provided name '{name}'" + AGENT_NAME_MISMATCH_URL = "Agent name in YAML '{yaml_name}' does not match URL name '{name}'" + YAML_INVALID = "Invalid YAML from {source}: {error}" + YAML_NOT_MAPPING = "YAML from {source} must contain a YAML mapping, not {type}" + YAML_VALIDATION_ERROR = "Validation error: {error}" + YAML_EMPTY = "Empty YAML content from {source}" + YAML_CONFIG_NOT_FOUND = "Config file not found: {path}" + YAML_PROMPT_FILE_NOT_FOUND = "Prompt file not found: {path}" + YAML_SYSTEM_PROMPT_FILE_DISALLOWED = ( + "system_prompt_file is not allowed in string-loaded YAML from {source}. " + "Inline the prompt in system_prompt instead." + ) + + # --- Agent --- + AGENT_NOT_FOUND = "Agent not found: {name}" + AGENT_CONFIG_NOT_FOUND = "Agent config metadata not found: {name}" + AGENT_CONFIG_NOT_FOUND_IN_STORE = "Agent config not found in store: {name}" + AGENT_CONFIG_ALREADY_EXISTS = "Agent config already exists: {name}" + AGENT_NO_FINAL_MESSAGES = "Graph completed but no messages were found in the final state." + AGENT_EXECUTION_ERROR = "Agent execution error: {error}" + AGENT_STREAMING_ERROR = "Streaming error: {error}" + AGENT_HITL_APPROVE_ERROR = "HITL approve error: {error}" + AGENT_HITL_REJECT_ERROR = "HITL reject error: {error}" + AGENT_HITL_EDIT_ERROR = "HITL edit error: {error}" + AGENT_STREAM_IDLE_TIMEOUT = ( + "Agent stream idle for {timeout}s (thread={thread_id}); aborting — a tool result " + "was likely lost (flaky transport)." + ) + AGENT_INVOKE_TIMEOUT = "Agent invoke timed out after {timeout}s (thread={thread_id})" + + # --- Thread --- + THREAD_NOT_FOUND = "Thread not found: {thread_id}" + THREAD_FAILED_CREATE = "Failed to create thread: {error}" + THREAD_FAILED_GET = "Failed to get thread {thread_id}: {error}" + THREAD_FAILED_LIST = "Failed to list threads: {error}" + THREAD_FAILED_DELETE = "Failed to delete thread {thread_id}: {error}" + THREAD_FAILED_ADD_MESSAGE = "Failed to add message to thread {thread_id}: {error}" + + # --- Storage / persistence --- + STORAGE_REPO_NOT_INITIALIZED = "Thread repository not initialized. Check PostgreSQL connectivity." + STORAGE_REGISTRY_NOT_INITIALIZED = "Agent registry not initialized. Check MinIO/PostgreSQL connectivity." + STORAGE_PERSISTENCE_NOT_INITIALIZED = ( + "Persistence layer not initialized. Check MinIO/PostgreSQL connectivity." + ) + STORAGE_FAILED_SAVE_AGENT_CONFIG = "Failed to save agent config metadata '{name}': {error}" + STORAGE_FAILED_GET_AGENT_CONFIG = "Failed to get agent config metadata '{name}': {error}" + STORAGE_FAILED_LIST_AGENT_CONFIG = "Failed to list agent config metadata: {error}" + STORAGE_FAILED_DELETE_AGENT_CONFIG = "Failed to delete agent config metadata '{name}': {error}" + STORAGE_FAILED_EXISTS_AGENT_CONFIG = "Failed to check existence of agent config '{name}': {error}" + STORAGE_FAILED_PERSIST_STREAM = "Failed to persist AI message after stream: {error}" + + # --- HITL --- + INVALID_HITL_ACTION = "Unsupported HITL action: {action}" + + # --- MCP --- + MCP_CONNECTION_ERROR = "Failed to connect to MCP servers: {error}" + MCP_TOOL_LOAD_ERROR = "Failed to load MCP tools: {error}" + MCP_TOOL_CALL_TIMEOUT = "MCP tool '{name}' timed out after {timeout}s" + + # --- Prompt management --- + PROMPT_MANAGER_NOT_INITIALIZED = "Prompt manager client not initialized" + PROMPT_NOT_FOUND = "Prompt not found: {identifier}" + PROMPT_ALREADY_EXISTS = "Prompt already exists: {identifier}" + PROMPT_MANAGER_UNAVAILABLE = "Prompt manager unavailable during '{operation}' for '{identifier}': {error}" + PROMPT_MANAGER_SERVER_ERROR = ( + "Prompt manager server error ({status_code}) during '{operation}' for '{identifier}'" + ) + + +def tmpl(template: str) -> Template: + """Return a ``string.Template`` for ``$name`` style templates when needed.""" + return Template(template) diff --git a/src/domain/errors/prompt.py b/src/domain/errors/prompt.py new file mode 100644 index 0000000..176fac6 --- /dev/null +++ b/src/domain/errors/prompt.py @@ -0,0 +1,28 @@ +"""Prompt management domain errors.""" + +from src.domain.errors.base import DomainError +from src.domain.errors.codes import ErrorCode + + +class PromptError(DomainError): + """Base error for prompt management operations.""" + + status_code = ErrorCode.INTERNAL_SERVER_ERROR + + +class PromptNotFoundError(PromptError): + """Prompt identifier not found in the prompt manager.""" + + status_code = ErrorCode.NOT_FOUND + + +class PromptAlreadyExistsError(PromptError): + """A prompt with the given identifier already exists.""" + + status_code = ErrorCode.CONFLICT + + +class PromptManagerUnavailableError(PromptError): + """The prompt manager backend is unreachable or returned a server error.""" + + status_code = ErrorCode.SERVICE_UNAVAILABLE diff --git a/src/domain/errors/storage.py b/src/domain/errors/storage.py new file mode 100644 index 0000000..ce4f074 --- /dev/null +++ b/src/domain/errors/storage.py @@ -0,0 +1,10 @@ +"""Storage / persistence domain errors.""" + +from src.domain.errors.base import DomainError +from src.domain.errors.codes import ErrorCode + + +class StorageError(DomainError): + """Storage infrastructure error (database, object store).""" + + status_code = ErrorCode.SERVICE_UNAVAILABLE diff --git a/src/domain/errors/thread.py b/src/domain/errors/thread.py new file mode 100644 index 0000000..86c5789 --- /dev/null +++ b/src/domain/errors/thread.py @@ -0,0 +1,10 @@ +"""Thread-related domain errors.""" + +from src.domain.errors.base import DomainError +from src.domain.errors.codes import ErrorCode + + +class ThreadNotFoundError(DomainError): + """Conversation thread not found.""" + + status_code = ErrorCode.NOT_FOUND diff --git a/src/domain/exceptions.py b/src/domain/exceptions.py deleted file mode 100644 index 4207c32..0000000 --- a/src/domain/exceptions.py +++ /dev/null @@ -1,73 +0,0 @@ -class DomainError(Exception): - """Base domain exception.""" - - pass - - -class ConfigError(DomainError): - """Configuration error.""" - - pass - - -class ConfigNotFoundError(ConfigError): - """Configuration file not found.""" - - pass - - -class ConfigValidationError(ConfigError): - """Schema validation error.""" - - def __init__(self, errors: list[dict]): - self.errors = errors - messages = [f" - {e.get('loc', '?')}: {e.get('msg', '?')}" for e in errors] - super().__init__("Validation errors:\n" + "\n".join(messages)) - - -class ThreadNotFoundError(DomainError): - """Thread not found.""" - - pass - - -class AgentError(DomainError): - """Agent execution error.""" - - pass - - -class AgentNotFoundError(DomainError): - """Agent not found.""" - - pass - - -class AgentConfigAlreadyExistsError(DomainError): - """Agent configuration already exists.""" - - pass - - -class StorageError(DomainError): - """Storage infrastructure error.""" - - pass - - -class McpError(DomainError): - """Base error for MCP operations.""" - - pass - - -class McpConnectionError(McpError): - """Error connecting to MCP server.""" - - pass - - -class McpToolLoadError(McpError): - """Error loading MCP tools.""" - - pass diff --git a/src/domain/logging/messages.py b/src/domain/logging/messages.py new file mode 100644 index 0000000..81fecf1 --- /dev/null +++ b/src/domain/logging/messages.py @@ -0,0 +1,249 @@ +"""Centralized log message templates. + +All log message strings used across the application are declared here so they +are discoverable, non-duplicated, and easy to audit. Callers reference these +constants instead of inlining message text, e.g.:: + + logger.info(LogMessage.AGENT_CONFIG_CREATED, agent_name) + +Each template may contain ``%s``/``%d`` style placeholders consumed by the +stdlib logging lazy interpolation. +""" + + +from enum import StrEnum + + +class LogMessage(StrEnum): + """Catalog of centralized log message templates (stdlib ``%`` style).""" + + # --- Application lifecycle --- + APP_STARTUP_INITIATED = "Application startup initiated" + APP_STARTUP_COMPLETE = "Application startup complete" + APP_PERSISTENCE_INITIALIZED = "Persistence initialized" + APP_PERSISTENCE_INIT_FAILED = "Failed to initialize persistence, falling back to filesystem registry" + APP_SHUTDOWN_INITIATED = "Application shutdown initiated" + APP_SHUTDOWN_COMPLETE = "Application shutdown complete" + APP_PERSISTENCE_CLOSED = "Persistence closed" + APP_PERSISTENCE_CLOSE_FAILED = "Error closing persistence" + APP_MCP_LOADER_CLOSED = "MCP tool loader closed" + APP_MCP_LOADER_CLOSE_FAILED = "Error closing MCP tool loader" + APP_TRACING_SHUTDOWN = "Tracing provider shut down" + APP_TRACING_SHUTDOWN_FAILED = "Error shutting down tracing provider" + APP_MIGRATIONS_RUNNING = "Running database migrations..." + APP_MIGRATIONS_DONE = "Database migrations completed" + DEPENDENCIES_INITIALIZED = "Dependencies initialized" + + # --- Dependency wiring / persistence init --- + TRACING_LANGFUSE_INIT = "Initializing Langfuse tracing provider (host=%s)" + TRACING_PHOENIX_INIT = "Initializing Phoenix tracing provider (endpoint=%s)" + TRACING_DISABLED = "Tracing disabled, using NoopTracingProvider" + PERSISTENCE_INITIALIZING = "Initializing persistence layer" + SQLALCHEMY_ENGINE_CREATED = "SQLAlchemy async engine created (pool: AsyncAdaptedQueuePool, size=20, max_overflow=20)" + POSTGRES_REPOS_INITIALIZED = "PostgreSQL repositories initialized" + MINIO_STORE_INITIALIZED = "MinIO store initialized (bucket=%s)" + PERSISTENCE_REGISTRY_SET = "Persistence layer initialized, agent_registry set to PersistentAgentRegistry" + PERSISTENT_REGISTRY_CLOSED = "Persistent registry closed" + SQLALCHEMY_ENGINE_DISPOSED = "SQLAlchemy engine disposed" + + # --- Agent config management --- + AGENT_CONFIG_LISTED = "Listed %d agent configs" + AGENT_CONFIG_LISTED_FROM_REPO = "Listed %d agent configs from repository" + AGENT_CONFIG_GET = "Getting agent config: %s" + AGENT_CONFIG_CREATING = "Creating agent config: %s" + AGENT_CONFIG_CREATED = "Agent config created: %s" + AGENT_CONFIG_CREATED_UC = "Created agent config '%s'" + AGENT_CONFIG_UPDATING = "Updating agent config: %s" + AGENT_CONFIG_UPDATED = "Agent config updated: %s" + AGENT_CONFIG_UPDATED_UC = "Updated agent config '%s'" + AGENT_CONFIG_DELETING = "Deleting agent config: %s" + AGENT_CONFIG_DELETED = "Agent config deleted: %s" + AGENT_CONFIG_DELETED_UC = "Deleted agent config '%s'" + AGENT_CONFIG_METADATA_SAVED = "Saved agent config metadata '%s'" + AGENT_CONFIG_METADATA_DELETED = "Deleted agent config metadata '%s'" + AGENT_CONFIG_LOADED_FROM_STORE = "Loaded agent config '%s' from store" + + # --- Chat / messaging --- + CHAT_RECEIVE = "[thread=%s] POST /chat - message=%s" + CHAT_RESPONSE = "[thread=%s] Response status=%s content_len=%d" + CHAT_STREAM_RECEIVE = "[thread=%s] POST /chat/stream - message=%s" + CHAT_STREAM_COMPLETE = "[thread=%s] Stream complete, %d chunks" + CHAT_STREAM_ERROR = "[thread=%s] Stream error after %d chunks" + CHAT_SENDING = "Sending message to thread '%s' (agent=%s)" + CHAT_SENT = "Chat completed [thread=%s][agent=%s] elapsed=%.2fs status=%s" + CHAT_HITL = "HITL [thread=%s][agent=%s] elapsed=%.2fs status=%s" + CHAT_SENDING_HUMAN = "[thread=%s][agent=%s] Sending human message" + CHAT_SKIP_DUPLICATE_HUMAN = "[thread=%s] Skipping duplicate HUMAN message" + CHAT_SENDING_HITL = "[thread=%s][agent=%s] Sending HITL decision (action=%s)" + CHAT_STREAM_COMPLETE_UC = "Stream complete [thread=%s][agent=%s] %d chunks, elapsed=%.2fs, message=persisted" + CHAT_STREAM_PERSIST_FAILED = "Failed to persist AI message after stream [thread=%s][agent=%s]" + + # --- Threads --- + THREAD_CREATING = "Creating thread for agent=%s" + THREAD_CREATED = "Thread created: id=%s agent=%s" + THREAD_LISTED = "Listed %d threads" + THREAD_GETTING = "Getting thread=%s" + THREAD_DELETING = "Deleting thread=%s" + THREAD_DELETED = "Thread deleted: %s" + THREAD_MESSAGES_LISTED = "[thread=%s] Listed %d messages" + + # --- WebSocket --- + WS_CONNECTED = "[thread=%s] WebSocket connected" + WS_INVALID_JSON = "[thread=%s] Invalid JSON received: %s" + WS_MESSAGE_RECEIVED = "[thread=%s] WS message received: %s" + WS_STREAM_COMPLETE = "[thread=%s] WS stream complete, %d chunks" + WS_STREAM_ERROR = "[thread=%s] WS stream error after %d chunks" + WS_DISCONNECTED = "[thread=%s] WebSocket disconnected" + WS_UNEXPECTED_ERROR = "[thread=%s] WebSocket unexpected error" + + # --- Prompt management --- + PROMPT_CREATING = "Creating prompt: %s" + PROMPT_CREATED = "Prompt created: %s" + PROMPT_CREATED_SUCCESS = "Prompt created successfully: %s" + PROMPT_UPDATING = "Updating prompt: %s" + PROMPT_UPDATED = "Prompt updated: %s" + PROMPT_UPDATED_SUCCESS = "Prompt updated successfully: %s" + PROMPT_RETRIEVING = "Retrieving prompt: %s" + PROMPT_RETRIEVING_CONTENT = "Retrieving prompt content: %s" + PROMPT_RETRIEVED = "Retrieved prompt content for '%s' (version: %s, tags: %s)" + PROMPT_CREATE_ERROR = "Error creating prompt '%s'" + PROMPT_GET_ERROR = "Error getting prompt '%s'" + PROMPT_GET_CONTENT_ERROR = "Error getting prompt content '%s'" + PROMPT_UPDATE_ERROR = "Error updating prompt '%s'" + PROMPT_TAG_ADDED = "Added tag '%s' to prompt '%s'" + PROMPT_TAG_ADD_FAILED = "Failed to add tag '%s' to '%s': %s" + PROMPT_PROVIDER_INITIALIZED = "Prompt provider initialized base_url=%s timeout=%ss" + PROMPT_PROVIDER_INIT_FAILED = "Failed to initialize prompt provider client" + PROMPT_DESC_UPDATE_UNSUPPORTED = ( + "Provider does not support updating description on existing prompts — description change ignored for '%s'" + ) + + # --- Validation warnings (Pydantic validators) --- + VALIDATION_MSG_AND_HITL_EXCLUSIVE = "Provide either 'message' or HITL fields, not both" + VALIDATION_ACTION_REQUIRED = "'action' is required for HITL decisions" + VALIDATION_EDITS_REQUIRED = "'edits' is required for action 'edit'" + VALIDATION_PROMPTS_MUTUALLY_EXCLUSIVE = "system_prompt and system_prompt_file are mutually exclusive" + VALIDATION_COMMAND_REQUIRED = "'command' is required for stdio transport" + VALIDATION_URL_REQUIRED = "'url' is required for http transport" + + # --- Infrastructure --- + MCP_CONNECTING = "Connecting to MCP servers" + MCP_TOOLS_LOADED = "Loaded %d MCP tools" + MCP_TOOL_TIMEOUT = "MCP tool '%s' timed out after %ss" + YAML_LOADED = "Loaded config from %s" + DB_QUERY_FAILED = "Database operation failed: %s" + + # --- Chat use cases (send_message / stream_message) --- + CHAT_INVOKE_COMPLETE = "[thread=%s][agent=%s] Invoke elapsed=%.2fs, status=%s, len=%d" + CHAT_HITL_RECEIVED = "[thread=%s][agent=%s] HITL action=%s tool_call_id=%s" + CHAT_HITL_COMPLETE = "[thread=%s][agent=%s] HITL elapsed=%.2fs, status=%s" + CHAT_STREAM_STARTED = "[thread=%s][agent=%s] Stream started" + CHAT_STREAM_ERROR_UC = "[thread=%s][agent=%s] Stream error after %d chunks" + CHAT_STREAM_COMPLETE_PERSISTED = "[thread=%s][agent=%s] Stream complete, %d chunks, elapsed=%.2fs, message=persisted" + + # --- DeepAgent runner lifecycle --- + AGENT_INVOKING = "[thread=%s] Invoking agent" + AGENT_MESSAGE = "[thread=%s] Message: %s" + AGENT_INVOKE_COMPLETE = "[thread=%s] Invoke complete, status=%s, elapsed=%.2fs" + AGENT_EXECUTION_ERROR_LOG = "[thread=%s] Agent execution error" + AGENT_FIRST_CHUNK = "[thread=%s] First chunk received, elapsed=%.2fs" + AGENT_STREAMING = "[thread=%s] Streaming agent response" + AGENT_STREAMING_ERROR_LOG = "[thread=%s] Streaming error" + AGENT_STREAMING_WITH_MESSAGE = "[thread=%s] Streaming agent response with final message" + AGENT_STREAM_WITH_MESSAGE_COMPLETE = "[thread=%s] Stream with message complete, %d chunks, elapsed=%.2fs, status=%s" + AGENT_STREAM_COMPLETE = "[thread=%s] Stream complete, %d chunks, elapsed=%.2fs" + AGENT_STREAM_IDLE_TIMEOUT = "[thread=%s] Agent stream idle timeout after %ss" + AGENT_INVOKE_TIMEOUT = "[thread=%s] Agent invoke timeout after %ss" + HITL_APPROVE = "[thread=%s] HITL approve" + HITL_APPROVE_COMPLETE = "[thread=%s] HITL approve complete, elapsed=%.2fs" + HITL_APPROVE_ERROR_LOG = "HITL approve error" + HITL_REJECT = "[thread=%s] HITL reject, reason=%s" + HITL_REJECT_COMPLETE = "[thread=%s] HITL reject complete, elapsed=%.2fs" + HITL_REJECT_ERROR_LOG = "HITL reject error" + HITL_EDIT = "[thread=%s] HITL edit, tool_call_id=%s" + HITL_EDIT_COMPLETE = "[thread=%s] HITL edit complete, elapsed=%.2fs" + HITL_EDIT_ERROR_LOG = "HITL edit error" + + # --- DeepAgent runner / ToolNode patching --- + TOOLS_NODE_MISSING = "No 'tools' node found in graph; cannot patch handle_tool_errors" + TOOLS_NODE_NO_BOUND = "'tools' node has no 'bound' attribute; cannot patch handle_tool_errors" + TOOLNODE_PATCHED = "Patched ToolNode handle_tool_errors=True" + TOOLNODE_PATCH_MISSING_ATTR = "ToolNode bound object missing _handle_tool_errors; patch not applied" + STRUCTURED_RESPONSE_VALIDATION_FAILED = "Failed to validate structured_response against schema, returning raw data" + STRUCTURED_FIELD_STRIPPED = "Stripped extra field from structured_response: '%s'" + STRUCTURED_NESTED_FIELD_STRIPPED = "Stripped extra nested field: '%s.%s'" + + # --- Agent factory --- + AGENT_CREATING = "Creating agent '%s' (model=%s)" + AGENT_MCP_TOOLS_LOADING = "Loading MCP tools for agent '%s' (%d servers)" + AGENT_MCP_TOOLS_LOADED = "Loaded %d MCP tools for agent '%s'" + AGENT_TOOLS_TOTAL = "Agent '%s' tools: %d total" + AGENT_SUBAGENTS = "Agent '%s' has %d subagents" + AGENT_CREATE_ERROR = "Error creating agent '%s'" + AGENT_CREATED = "Agent '%s' created successfully" + TOOL_FORMAT_INVALID = "Invalid tool format '%s'" + TOOL_MODULE_NOT_FOUND = "Module not found for tool '%s'" + TOOL_ATTRIBUTE_NOT_FOUND = "Attribute '%s' not found in '%s'" + SUBAGENT_PROMPT_LOAD_FAILED = ( + "Could not load system prompt for sub-agent '%s' from Phoenix, using YAML instructions if available." + ) + + # --- Persistent agent registry --- + AGENT_CACHE_HIT = "Agent '%s' loaded from cache" + AGENT_BUILDING = "Building agent '%s' from persistent store" + AGENT_CACHED = "Agent '%s' ready and cached" + AGENT_CACHE_INVALIDATED = "Invalidated cached agent '%s'" + REGISTRY_CLOSING = "Closing persistent registry, clearing %d cached agents" + + # --- MinIO store --- + MINIO_CONFIG_UPLOADED = "Uploaded agent config '%s' to MinIO bucket '%s'" + MINIO_CONFIG_DELETED = "Deleted agent config '%s' from MinIO bucket '%s'" + MINIO_BUCKET_EXISTS = "MinIO bucket '%s' already exists" + MINIO_BUCKET_CREATED = "Created MinIO bucket '%s'" + + # --- MCP loader --- + MCP_CONNECT_FAILED = "Failed to connect to MCP servers" + MCP_TOOLS_LOAD_FAILED = "Failed to load MCP tools" + + # --- Phoenix prompt adapter --- + PHOENIX_PROMPT_PROVIDER_INITIALIZED = "PhoenixPromptManagerProvider initialized base_url=%s timeout=%ss" + PHOENIX_CLIENT_INIT_FAILED = "Failed to initialize Phoenix client" + PROMPT_VERSION_CREATED = "Created prompt '%s'" + PROMPT_VERSION_UPDATED = "Updated prompt '%s'" + PROMPT_TAG_ADD_ERROR = "Error adding tag '%s' to '%s'" + PHOENIX_DESC_UPDATE_UNSUPPORTED = ( + "Phoenix does not support updating description on existing prompts — description change ignored for '%s'" + ) + + # --- Phoenix tracing provider --- + PHOENIX_PROVIDER_INIT = "Initializing PhoenixTracingProvider with endpoint=%s, project_name=%s" + PHOENIX_TRACING_INITIALIZED = "Phoenix tracing initialized successfully" + PHOENIX_SPANS_FLUSHED = "Flushed pending spans to Phoenix" + PHOENIX_FLUSH_FAILED = "Error flushing spans to Phoenix" + PHOENIX_TRACING_SHUTDOWN = "Phoenix tracing provider shutdown complete" + TRACER_SHUTDOWN_FAILED = "Error shutting down tracer provider" + + # --- YAML config loader --- + YAML_PARSE_FAILED = "Invalid YAML from %s" + YAML_NOT_MAPPING_LOG = "YAML from %s must contain a YAML mapping, not %s" + YAML_VALIDATION_FAILED = "Validation error from %s" + CONFIG_FILE_NOT_FOUND_LOG = "Config file not found: %s" + PROMPT_FILE_NOT_FOUND_LOG = "Prompt file not found: %s" + YAML_EMPTY_LOG = "Empty YAML content from %s" + YAML_SYSTEM_PROMPT_FILE_DISALLOWED = "system_prompt_file is not allowed in string-loaded YAML from %s" + + # --- Exception handler log lines (main.py) --- + LOG_AGENT_CONFIG_ALREADY_EXISTS = "Agent config already exists: %s" + LOG_STORAGE_ERROR = "Storage error: %s" + LOG_AGENT_NOT_FOUND = "Agent not found: %s" + LOG_CONFIG_NOT_FOUND = "Config not found: %s" + LOG_THREAD_NOT_FOUND = "Thread not found: %s" + LOG_PROMPT_NOT_FOUND = "Prompt not found: %s" + LOG_PROMPT_ALREADY_EXISTS = "Prompt already exists: %s" + LOG_PROMPT_MANAGER_UNAVAILABLE = "Prompt manager unavailable: %s" + LOG_INVALID_HITL_ACTION = "Invalid HITL action: %s" + LOG_CONFIG_VALIDATION_ERROR = "Config validation error: %s errors=%s" + LOG_CONFIG_ERROR = "Config error: %s" + LOG_AGENT_ERROR = "Agent error: %s" + LOG_MCP_ERROR = "MCP error: %s" + LOG_UNHANDLED_DOMAIN_ERROR = "Unhandled domain error: %s" diff --git a/src/infrastructure/deepagent/adapter.py b/src/infrastructure/deepagent/adapter.py index b525889..5951519 100644 --- a/src/infrastructure/deepagent/adapter.py +++ b/src/infrastructure/deepagent/adapter.py @@ -1,3 +1,4 @@ +import asyncio import json import logging import re @@ -9,7 +10,9 @@ from src.domain.entities.message import Message, MessageRole, MessageStatus from src.domain.entities.stream_event import StreamEvent, StreamEventType -from src.domain.exceptions import AgentError +from src.domain.errors.agent import AgentError +from src.domain.errors.messages import ErrorMessage +from src.domain.logging.messages import LogMessage from src.domain.ports.agent_runner import AgentRunner from src.domain.ports.tracing_provider import TracingProvider @@ -17,10 +20,22 @@ class DeepAgentRunner(AgentRunner): - def __init__(self, graph, tracing_provider: TracingProvider | None = None, response_format_model: type[BaseModel] | None = None): + def __init__( + self, + graph, + tracing_provider: TracingProvider | None = None, + response_format_model: type[BaseModel] | None = None, + stream_idle_timeout: float = 120.0, + invoke_timeout: float = 120.0, + ): self._graph = graph self._tracing_provider = tracing_provider self._response_format_model = response_format_model + # Max idle window (s) between streamed chunks before the graph is considered + # stuck (e.g. a tool result was lost) and aborted. Max wall time (s) for a + # non-streaming invoke/HITL call. + self._stream_idle_timeout = stream_idle_timeout + self._invoke_timeout = invoke_timeout self._patch_tool_node_error_handling() def _patch_tool_node_error_handling(self) -> None: @@ -36,17 +51,17 @@ def _patch_tool_node_error_handling(self) -> None: """ tools_node = self._graph.nodes.get("tools") if tools_node is None: - logger.warning("No 'tools' node found in graph; cannot patch handle_tool_errors") + logger.warning(LogMessage.TOOLS_NODE_MISSING) return tool_node_impl = getattr(tools_node, "bound", None) if tool_node_impl is None: - logger.warning("'tools' node has no 'bound' attribute; cannot patch handle_tool_errors") + logger.warning(LogMessage.TOOLS_NODE_NO_BOUND) return if hasattr(tool_node_impl, "_handle_tool_errors"): tool_node_impl._handle_tool_errors = True - logger.info("Patched ToolNode handle_tool_errors=True") + logger.info(LogMessage.TOOLNODE_PATCHED) else: - logger.warning("ToolNode bound object missing _handle_tool_errors; patch not applied") + logger.warning(LogMessage.TOOLNODE_PATCH_MISSING_ATTR) @staticmethod def _try_parse_json(content: str) -> dict | None: @@ -79,7 +94,7 @@ def _validate_structured_response(self, data: dict) -> dict: self._log_extra_fields(data, cleaned) return cleaned except Exception: - logger.warning("Failed to validate structured_response against schema, returning raw data") + logger.warning(LogMessage.STRUCTURED_RESPONSE_VALIDATION_FAILED) return data @staticmethod @@ -87,11 +102,11 @@ def _log_extra_fields(original: dict, cleaned: dict) -> None: """Log any top-level or nested fields that were stripped.""" for key in original: if key not in cleaned: - logger.warning("Stripped extra field from structured_response: '%s'", key) + logger.warning(LogMessage.STRUCTURED_FIELD_STRIPPED, key) elif isinstance(original[key], dict) and isinstance(cleaned[key], dict): for sub_key in original[key]: if sub_key not in cleaned[key]: - logger.warning("Stripped extra nested field: '%s.%s'", key, sub_key) + logger.warning(LogMessage.STRUCTURED_NESTED_FIELD_STRIPPED, key, sub_key) @staticmethod def _is_nonblank_str(val: object) -> bool: @@ -145,7 +160,7 @@ def _extract_structured_response(self, messages: list) -> dict | None: def _build_response(self, result: dict, config: dict, thinking: str | None) -> Message: messages = result.get("messages", []) if not messages: - raise AgentError("Graph completed but no messages were found in the final state.") + raise AgentError(ErrorMessage.AGENT_NO_FINAL_MESSAGES) last_message = messages[-1] all_tool_calls = getattr(last_message, "tool_calls", None) or [] state = self._graph.get_state(config) @@ -182,21 +197,29 @@ def _build_response(self, result: dict, config: dict, thinking: str | None) -> M async def invoke(self, thread_id: str, message: str) -> Message: config = self._build_config(thread_id) - logger.info("[thread=%s] Invoking agent", thread_id) - logger.info("[thread=%s] Message: %s", thread_id, message[:200]) + logger.info(LogMessage.AGENT_INVOKING, thread_id) + logger.info(LogMessage.AGENT_MESSAGE, thread_id, message[:200]) try: start = time.monotonic() - result = await self._graph.ainvoke( - {"messages": [{"role": "human", "content": message}]}, - config=config, + result = await asyncio.wait_for( + self._graph.ainvoke( + {"messages": [{"role": "human", "content": message}]}, + config=config, + ), + timeout=self._invoke_timeout, ) elapsed = time.monotonic() - start response = self._build_response(result, config, None) - logger.info("[thread=%s] Invoke complete, status=%s, elapsed=%.2fs", thread_id, response.status, elapsed) + logger.info(LogMessage.AGENT_INVOKE_COMPLETE, thread_id, response.status, elapsed) return response + except TimeoutError as e: + logger.error(LogMessage.AGENT_INVOKE_TIMEOUT, thread_id, self._invoke_timeout) + raise AgentError( + ErrorMessage.AGENT_INVOKE_TIMEOUT.format(thread_id=thread_id, timeout=self._invoke_timeout) + ) from e except Exception as e: - logger.exception("[thread=%s] Agent execution error", thread_id) - raise AgentError(f"Agent execution error: {e}") from e + logger.exception(LogMessage.AGENT_EXECUTION_ERROR_LOG, thread_id) + raise AgentError(ErrorMessage.AGENT_EXECUTION_ERROR.format(error=e)) from e async def _yield_chunks( self, thread_id: str, message: str, config: dict, stats: dict @@ -204,42 +227,63 @@ async def _yield_chunks( start = time.monotonic() first_chunk = True chunk_count = 0 - async for chunk, _metadata in self._graph.astream( - {"messages": [{"role": "human", "content": message}]}, - config=config, - stream_mode="messages", - ): - classification = self._classify_chunk(chunk) - if classification: - event_type, data = classification - if first_chunk: - logger.info("[thread=%s] First chunk received, elapsed=%.2fs", thread_id, time.monotonic() - start) - first_chunk = False - chunk_count += 1 - yield StreamEvent(type=event_type, data=data) + stream_iter = aiter( + self._graph.astream( + {"messages": [{"role": "human", "content": message}]}, + config=config, + stream_mode="messages", + ) + ) + # Consume chunks with an idle timeout: if no chunk arrives within the window + # the graph is considered stuck (e.g. a tool result was lost) and aborted. + try: + while True: + try: + chunk, _metadata = await asyncio.wait_for( + anext(stream_iter), timeout=self._stream_idle_timeout + ) + except StopAsyncIteration: + break + except TimeoutError as e: + logger.error(LogMessage.AGENT_STREAM_IDLE_TIMEOUT, thread_id, self._stream_idle_timeout) + raise AgentError( + ErrorMessage.AGENT_STREAM_IDLE_TIMEOUT.format(thread_id=thread_id, timeout=self._stream_idle_timeout) + ) from e + classification = self._classify_chunk(chunk) + if classification: + event_type, data = classification + if first_chunk: + logger.info(LogMessage.AGENT_FIRST_CHUNK, thread_id, time.monotonic() - start) + first_chunk = False + chunk_count += 1 + yield StreamEvent(type=event_type, data=data) + finally: + aclose = getattr(stream_iter, "aclose", None) + if aclose is not None: + await aclose() stats["chunk_count"] = chunk_count stats["elapsed"] = time.monotonic() - start async def stream(self, thread_id: str, message: str) -> AsyncIterator[StreamEvent]: config = self._build_config(thread_id) - logger.info("[thread=%s] Streaming agent response", thread_id) + logger.info(LogMessage.AGENT_STREAMING, thread_id) try: stats: dict = {} async for event in self._yield_chunks(thread_id, message, config, stats): yield event logger.info( - "[thread=%s] Stream complete, %d chunks, elapsed=%.2fs", + LogMessage.AGENT_STREAM_COMPLETE, thread_id, stats["chunk_count"], stats["elapsed"], ) except Exception as e: - logger.exception("[thread=%s] Streaming error", thread_id) - raise AgentError(f"Streaming error: {e}") from e + logger.exception(LogMessage.AGENT_STREAMING_ERROR_LOG, thread_id) + raise AgentError(ErrorMessage.AGENT_STREAMING_ERROR.format(error=e)) from e async def stream_with_message(self, thread_id: str, message: str) -> AsyncIterator[StreamEvent]: config = self._build_config(thread_id) - logger.info("[thread=%s] Streaming agent response with final message", thread_id) + logger.info(LogMessage.AGENT_STREAMING_WITH_MESSAGE, thread_id) try: stats: dict = {} thinking_parts = [] @@ -253,7 +297,7 @@ async def stream_with_message(self, thread_id: str, message: str) -> AsyncIterat thinking = "".join(thinking_parts) if thinking_parts else None response = self._build_response(result, config, thinking) logger.info( - "[thread=%s] Stream with message complete, %d chunks, elapsed=%.2fs, status=%s", + LogMessage.AGENT_STREAM_WITH_MESSAGE_COMPLETE, thread_id, stats["chunk_count"], stats["elapsed"], @@ -261,26 +305,26 @@ async def stream_with_message(self, thread_id: str, message: str) -> AsyncIterat ) yield StreamEvent(type=StreamEventType.MESSAGE, data=response.model_dump_json()) except Exception as e: - logger.exception("[thread=%s] Streaming error", thread_id) - raise AgentError(f"Streaming error: {e}") from e + logger.exception(LogMessage.AGENT_STREAMING_ERROR_LOG, thread_id) + raise AgentError(ErrorMessage.AGENT_STREAMING_ERROR.format(error=e)) from e async def approve_hitl(self, thread_id: str, _tool_call_id: str) -> Message: config = self._build_config(thread_id) - logger.info("[thread=%s] HITL approve", thread_id) + logger.info(LogMessage.HITL_APPROVE, thread_id) try: start = time.monotonic() result = await self._graph.ainvoke(Command(resume={"decisions": [{"type": "approve"}]}), config=config) elapsed = time.monotonic() - start response = self._build_response(result, config, None) - logger.info("[thread=%s] HITL approve complete, elapsed=%.2fs", thread_id, elapsed) + logger.info(LogMessage.HITL_APPROVE_COMPLETE, thread_id, elapsed) return response except Exception as e: - logger.exception("HITL approve error") - raise AgentError(f"HITL approve error: {e}") from e + logger.exception(LogMessage.HITL_APPROVE_ERROR_LOG) + raise AgentError(ErrorMessage.AGENT_HITL_APPROVE_ERROR.format(error=e)) from e async def reject_hitl(self, thread_id: str, _tool_call_id: str, reason: str | None = None) -> Message: config = self._build_config(thread_id) - logger.info("[thread=%s] HITL reject, reason=%s", thread_id, reason) + logger.info(LogMessage.HITL_REJECT, thread_id, reason) try: start = time.monotonic() result = await self._graph.ainvoke( @@ -288,15 +332,15 @@ async def reject_hitl(self, thread_id: str, _tool_call_id: str, reason: str | No ) elapsed = time.monotonic() - start response = self._build_response(result, config, None) - logger.info("[thread=%s] HITL reject complete, elapsed=%.2fs", thread_id, elapsed) + logger.info(LogMessage.HITL_REJECT_COMPLETE, thread_id, elapsed) return response except Exception as e: - logger.exception("HITL reject error") - raise AgentError(f"HITL reject error: {e}") from e + logger.exception(LogMessage.HITL_REJECT_ERROR_LOG) + raise AgentError(ErrorMessage.AGENT_HITL_REJECT_ERROR.format(error=e)) from e async def edit_hitl(self, thread_id: str, tool_call_id: str, edits: dict) -> Message: config = self._build_config(thread_id) - logger.info("[thread=%s] HITL edit, tool_call_id=%s", thread_id, tool_call_id) + logger.info(LogMessage.HITL_EDIT, thread_id, tool_call_id) try: start = time.monotonic() state = self._graph.get_state(config) @@ -317,8 +361,8 @@ async def edit_hitl(self, thread_id: str, tool_call_id: str, edits: dict) -> Mes ) elapsed = time.monotonic() - start response = self._build_response(result, config, None) - logger.info("[thread=%s] HITL edit complete, elapsed=%.2fs", thread_id, elapsed) + logger.info(LogMessage.HITL_EDIT_COMPLETE, thread_id, elapsed) return response except Exception as e: - logger.exception("HITL edit error") - raise AgentError(f"HITL edit error: {e}") from e + logger.exception(LogMessage.HITL_EDIT_ERROR_LOG) + raise AgentError(ErrorMessage.AGENT_HITL_EDIT_ERROR.format(error=e)) from e diff --git a/src/infrastructure/deepagent/factory.py b/src/infrastructure/deepagent/factory.py index 09a3286..d9682e2 100644 --- a/src/infrastructure/deepagent/factory.py +++ b/src/infrastructure/deepagent/factory.py @@ -13,6 +13,7 @@ from pydantic import create_model from src.domain.entities.agent_config import AgentConfig, BackendType +from src.domain.logging.messages import LogMessage from src.domain.ports.mcp_tool_loader import McpToolLoader from src.domain.ports.prompt_manager import PromptManager from src.infrastructure.deepagent.schema_utils import make_validation_model @@ -74,7 +75,7 @@ def _resolve_tools(config: AgentConfig) -> list | None: for tool_path in config.tools: module_path, _, attr_name = tool_path.rpartition(":") if not module_path or not attr_name: - logger.error(f"Invalid tool format '{tool_path}'") + logger.error(LogMessage.TOOL_FORMAT_INVALID, tool_path) raise ValueError( f"Invalid tool format '{tool_path}'. " f"Expected: 'module.path:tool_name' (e.g., 'mypackage.tools:my_tool')" @@ -82,12 +83,12 @@ def _resolve_tools(config: AgentConfig) -> list | None: try: module = importlib.import_module(module_path) except ModuleNotFoundError as e: - logger.exception(f"Module not found for tool '{tool_path}'") + logger.exception(LogMessage.TOOL_MODULE_NOT_FOUND, tool_path) raise ValueError(f"Module not found for tool '{tool_path}': {e}") from e if not hasattr(module, attr_name): available = [a for a in dir(module) if not a.startswith("_")] - logger.error(f"Attribute '{attr_name}' not found in '{module_path}'") + logger.error(LogMessage.TOOL_ATTRIBUTE_NOT_FOUND, attr_name, module_path) raise ValueError(f"Attribute '{attr_name}' not found in '{module_path}'. Available: {available}") tools.append(getattr(module, attr_name)) return tools @@ -99,7 +100,13 @@ def _resolve_backend(config: AgentConfig): case BackendType.STATE: return None # Defaut de create_deep_agent case BackendType.FILESYSTEM: - return FilesystemBackend(root_dir=config.backend.root_dir or "./workspace") + # virtual_mode=False keeps the historical behaviour (root_dir-bounded + # persistence without virtual path routing). Specified explicitly to + # silence the deepagents>=0.6 default-change deprecation warning. + return FilesystemBackend( + root_dir=config.backend.root_dir or "./workspace", + virtual_mode=False, + ) case BackendType.STORE: return lambda rt: StoreBackend(rt) case BackendType.COMPOSITE: @@ -149,7 +156,7 @@ async def _resolve_subagents( content = await prompt_manager.get_prompt_content(sa.name) instructions = content.get("content") except Exception: - logger.warning(f"Could not load system prompt for sub-agent '{sa.name}' from Phoenix, using YAML instructions if available.") + logger.warning(LogMessage.SUBAGENT_PROMPT_LOAD_FAILED, sa.name) instructions = sa.instructions if sa.response_format: @@ -196,7 +203,7 @@ async def create_agent_from_config( Returns: Tuple of (compiled agent graph, response_format_model or None). """ - logger.info("Creating agent '%s' (model=%s)", config.name, config.model) + logger.info(LogMessage.AGENT_CREATING, config.name, config.model) checkpointer = MemorySaver() store = InMemoryStore() interrupt_on = _resolve_interrupt_on(config) @@ -205,11 +212,11 @@ async def create_agent_from_config( local_tools = _resolve_tools(config) mcp_tools: list = [] if config.mcp_servers and mcp_tool_loader: - logger.info("Loading MCP tools for agent '%s' (%d servers)", config.name, len(config.mcp_servers)) + logger.info(LogMessage.AGENT_MCP_TOOLS_LOADING, config.name, len(config.mcp_servers)) mcp_tools = await mcp_tool_loader.load_tools(config.mcp_servers) - logger.info("Loaded %d MCP tools for agent '%s'", len(mcp_tools), config.name) + logger.info(LogMessage.AGENT_MCP_TOOLS_LOADED, len(mcp_tools), config.name) all_tools = (local_tools or []) + mcp_tools if (local_tools or mcp_tools) else None - logger.info("Agent '%s' tools: %d total", config.name, len(all_tools) if all_tools else 0) + logger.info(LogMessage.AGENT_TOOLS_TOTAL, config.name, len(all_tools) if all_tools else 0) if prompt_manager: system_prompt = await get_system_prompt_from_phoenix(config.name, prompt_manager) @@ -251,13 +258,13 @@ async def create_agent_from_config( subagents = await _resolve_subagents(config, mcp_tool_loader, prompt_manager) if subagents: kwargs["subagents"] = subagents - logger.info("Agent '%s' has %d subagents", config.name, len(subagents)) + logger.info(LogMessage.AGENT_SUBAGENTS, config.name, len(subagents)) try: graph = create_deep_agent(**kwargs) except Exception: - logger.exception("Error creating agent '%s'", config.name) + logger.exception(LogMessage.AGENT_CREATE_ERROR, config.name) raise - logger.info("Agent '%s' created successfully", config.name) + logger.info(LogMessage.AGENT_CREATED, config.name) return graph, response_format_model diff --git a/src/infrastructure/logging.py b/src/infrastructure/logging.py new file mode 100644 index 0000000..8fc1ebf --- /dev/null +++ b/src/infrastructure/logging.py @@ -0,0 +1,86 @@ +"""Centralized logging configuration. + +This module is the single place where logging is configured for the whole +application. Log *message templates* live in ``src/domain/logging/messages.py``; +this module only handles formatting, levels, handler wiring and the per-request +correlation id (``request_id``) propagation. +""" + +import logging +import uuid +from contextvars import ContextVar + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint + +from src.config import Settings + +# Per-request correlation id. Populated by RequestIdMiddleware and read by the +# RequestIdFilter so every log line within a request carries the same id. +request_id_ctx: ContextVar[str] = ContextVar("request_id", default="-") + +# Log format including the correlation id slot. +_LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | request_id=%(request_id)s | %(message)s" +_LOG_DATEFMT = "%Y-%m-%d %H:%M:%S" + +# Third-party loggers that should be quieter than the application level. +_NOISY_LOGGERS = ( + "uvicorn", + "uvicorn.access", + "uvicorn.error", + "sqlalchemy.engine", + "httpx", + "httpcore", + "asyncio", + "cachetools", +) + + +class RequestIdFilter(logging.Filter): + """Inject the current ``request_id`` context var into every log record.""" + + def filter(self, record: logging.LogRecord) -> bool: + record.request_id = request_id_ctx.get() + return True + + +def configure_logging(settings: Settings) -> None: + """Configure root logging for the application. + + Idempotent: safe to call from ``main.py`` and ``alembic/env.py``. + + Args: + settings: Application settings (reads ``log_level``). + """ + level = getattr(logging, settings.log_level.upper(), logging.INFO) + + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter(_LOG_FORMAT, datefmt=_LOG_DATEFMT)) + handler.addFilter(RequestIdFilter()) + + root = logging.getLogger() + # Remove existing handlers so repeated calls (tests, alembic) stay clean. + root.handlers = [handler] + root.setLevel(level) + + for name in _NOISY_LOGGERS: + logging.getLogger(name).setLevel(logging.WARNING) + + +class RequestIdMiddleware(BaseHTTPMiddleware): + """Assign/propagate a correlation id for every HTTP request. + + Reads an incoming ``X-Request-ID`` header if present, otherwise generates a + new one. The id is stored in the ``request_id_ctx`` context var (so it lands + in every log line) and echoed back on the response. + """ + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex + token = request_id_ctx.set(request_id) + try: + response = await call_next(request) + finally: + request_id_ctx.reset(token) + response.headers["X-Request-ID"] = request_id + return response diff --git a/src/infrastructure/mcp/adapter.py b/src/infrastructure/mcp/adapter.py index b979796..dbc7724 100644 --- a/src/infrastructure/mcp/adapter.py +++ b/src/infrastructure/mcp/adapter.py @@ -1,23 +1,37 @@ import asyncio import logging +from datetime import timedelta from typing import Any -from langchain_core.tools import BaseTool, StructuredTool +from langchain_core.tools import BaseTool, StructuredTool, ToolException from langchain_mcp_adapters.client import MultiServerMCPClient from src.domain.entities.mcp_server_config import McpServerConfig, McpTransportType -from src.domain.exceptions import McpConnectionError, McpToolLoadError +from src.domain.errors.mcp import McpConnectionError, McpToolLoadError +from src.domain.errors.messages import ErrorMessage +from src.domain.logging.messages import LogMessage from src.domain.ports.mcp_tool_loader import McpToolLoader from src.infrastructure.env_utils import resolve_env_vars logger = logging.getLogger(__name__) +# Streamable HTTP transport tuning. Explicit values make client/server behaviour +# deterministic instead of relying on library defaults, and reduce the reconnect +# storms seen when the SSE GET stream drops between tool calls. +# Keep aligned with the server (mcp-raganything / fastmcp) SDK version. +_STREAMABLE_HTTP_TIMEOUT = timedelta(seconds=30) +_STREAMABLE_HTTP_SSE_READ_TIMEOUT = timedelta(minutes=10) + class LangchainMcpToolLoader(McpToolLoader): """Adapter MCP utilisant langchain-mcp-adapters pour charger des outils.""" - def __init__(self) -> None: + def __init__(self, tool_timeout: float = 60.0) -> None: self._clients: list[MultiServerMCPClient] = [] + # Per-call timeout for each MCP tool invocation. A hung tool is converted + # to a ToolException -> ToolMessage(error) so the agent can recover + # (retry/skip) instead of stalling or being killed. + self._tool_timeout = tool_timeout async def load_tools(self, configs: list[McpServerConfig]) -> list[Any]: """Charge les outils depuis les serveurs MCP configures. @@ -47,6 +61,8 @@ async def load_tools(self, configs: list[McpServerConfig]) -> list[Any]: "transport": "streamable_http", "url": config.url, "headers": self._resolve_env_vars(config.headers), + "timeout": _STREAMABLE_HTTP_TIMEOUT, + "sse_read_timeout": _STREAMABLE_HTTP_SSE_READ_TIMEOUT, } if config.auth_token: server_config["auth_token"] = config.auth_token @@ -58,11 +74,11 @@ async def load_tools(self, configs: list[McpServerConfig]) -> list[Any]: tools = await client.get_tools() return self._patch_sync_support(tools) except ConnectionError as e: - logger.exception("Failed to connect to MCP servers") - raise McpConnectionError(f"Failed to connect to MCP servers: {e}") from e + logger.exception(LogMessage.MCP_CONNECT_FAILED) + raise McpConnectionError(ErrorMessage.MCP_CONNECTION_ERROR.format(error=e)) from e except Exception as e: - logger.exception("Failed to load MCP tools") - raise McpToolLoadError(f"Failed to load MCP tools: {e}") from e + logger.exception(LogMessage.MCP_TOOLS_LOAD_FAILED) + raise McpToolLoadError(ErrorMessage.MCP_TOOL_LOAD_ERROR.format(error=e)) from e async def close(self) -> None: """Ferme toutes les connexions MCP ouvertes. @@ -78,21 +94,33 @@ async def close(self) -> None: await result self._clients.clear() - @staticmethod - def _patch_sync_support(tools: list[BaseTool]) -> list[BaseTool]: - """Patch MCP tools to support sync invocation. + def _patch_sync_support(self, tools: list[BaseTool]) -> list[BaseTool]: + """Patch MCP tools: per-call timeout + sync invocation support. - langchain-mcp-adapters creates async-only StructuredTools (coroutine only, no func). - deepagents invokes subagent tools synchronously, which crashes with NotImplementedError. - This recreates each tool with a sync func that runs the coroutine via asyncio. + langchain-mcp-adapters creates async-only StructuredTools (coroutine only, + no func). deepagents invokes subagent tools synchronously, which crashes + with NotImplementedError. We recreate each tool with: + - a coroutine wrapped in asyncio.wait_for(tool_timeout): a hung tool + raises ToolException -> ToolMessage(error) so the agent RECOVERS + (the LLM can retry/skip) instead of stalling or being killed. + - a sync func that runs the same timed coroutine for deepagents. """ patched = [] for tool in tools: if isinstance(tool, StructuredTool) and tool.coroutine is not None and tool.func is None: - coro = tool.coroutine + original_coro = tool.coroutine + timeout = self._tool_timeout + tool_name = tool.name + + async def timed_coro(*args, _oc=original_coro, _to=timeout, _name=tool_name, **kwargs): + try: + return await asyncio.wait_for(_oc(*args, **kwargs), timeout=_to) + except TimeoutError as e: + logger.warning(LogMessage.MCP_TOOL_TIMEOUT, _name, _to) + raise ToolException(ErrorMessage.MCP_TOOL_CALL_TIMEOUT.format(name=_name, timeout=_to)) from e - def sync_wrapper(*args, _coro=coro, **kwargs): - return asyncio.get_event_loop().run_until_complete(_coro(*args, **kwargs)) + def sync_wrapper(*args, _tc=timed_coro, **kwargs): + return asyncio.get_event_loop().run_until_complete(_tc(*args, **kwargs)) patched.append( StructuredTool( @@ -100,7 +128,7 @@ def sync_wrapper(*args, _coro=coro, **kwargs): description=tool.description, args_schema=tool.args_schema, func=sync_wrapper, - coroutine=coro, + coroutine=timed_coro, response_format=tool.response_format, metadata=tool.metadata, ) diff --git a/src/infrastructure/minio_store/adapter.py b/src/infrastructure/minio_store/adapter.py index 00cce5d..6deb2b7 100644 --- a/src/infrastructure/minio_store/adapter.py +++ b/src/infrastructure/minio_store/adapter.py @@ -4,7 +4,9 @@ from miniopy_async import Minio from miniopy_async.error import S3Error -from src.domain.exceptions import AgentNotFoundError +from src.domain.errors.agent import AgentNotFoundError +from src.domain.errors.messages import ErrorMessage +from src.domain.logging.messages import LogMessage from src.domain.ports.agent_config_store import AgentConfigStore logger = logging.getLogger(__name__) @@ -31,7 +33,7 @@ async def put(self, name: str, yaml_content: str) -> None: length=len(data), content_type="application/x-yaml", ) - logger.info("Uploaded agent config '%s' to MinIO bucket '%s'", name, self._bucket) + logger.info(LogMessage.MINIO_CONFIG_UPLOADED, name, self._bucket) async def get(self, name: str) -> str: """Download and return YAML content for the given agent name. @@ -47,7 +49,7 @@ async def get(self, name: str) -> str: return data.decode("utf-8") except S3Error as e: if e.code == "NoSuchKey": - raise AgentNotFoundError(f"Agent config not found in store: {name}") from e + raise AgentNotFoundError(ErrorMessage.AGENT_CONFIG_NOT_FOUND_IN_STORE.format(name=name)) from e raise async def delete(self, name: str) -> None: @@ -60,10 +62,10 @@ async def delete(self, name: str) -> None: await self._client.stat_object(self._bucket, self._key(name)) except S3Error as e: if e.code == "NoSuchKey": - raise AgentNotFoundError(f"Agent config not found in store: {name}") from e + raise AgentNotFoundError(ErrorMessage.AGENT_CONFIG_NOT_FOUND_IN_STORE.format(name=name)) from e raise await self._client.remove_object(self._bucket, self._key(name)) - logger.info("Deleted agent config '%s' from MinIO bucket '%s'", name, self._bucket) + logger.info(LogMessage.MINIO_CONFIG_DELETED, name, self._bucket) async def exists(self, name: str) -> bool: """Check whether a YAML object exists for the given agent name.""" @@ -84,7 +86,7 @@ async def list_all(self) -> list[str]: async def ensure_bucket(self) -> None: """Create the bucket if it does not already exist.""" if await self._client.bucket_exists(self._bucket): - logger.info("MinIO bucket '%s' already exists", self._bucket) + logger.info(LogMessage.MINIO_BUCKET_EXISTS, self._bucket) return await self._client.make_bucket(self._bucket) - logger.info("Created MinIO bucket '%s'", self._bucket) + logger.info(LogMessage.MINIO_BUCKET_CREATED, self._bucket) diff --git a/src/infrastructure/persistent_registry/adapter.py b/src/infrastructure/persistent_registry/adapter.py index 484dc9c..b22e9f7 100644 --- a/src/infrastructure/persistent_registry/adapter.py +++ b/src/infrastructure/persistent_registry/adapter.py @@ -1,6 +1,7 @@ import asyncio import logging +from src.domain.logging.messages import LogMessage from src.domain.ports.agent_config_loader import AgentConfigLoader from src.domain.ports.agent_config_repository import AgentConfigRepository from src.domain.ports.agent_config_store import AgentConfigStore @@ -26,6 +27,8 @@ def __init__( mcp_tool_loader: McpToolLoader, tracing_provider: TracingProvider | None = None, prompt_manager: PromptManager | None = None, + stream_idle_timeout: float = 120.0, + invoke_timeout: float = 120.0, ) -> None: self._config_loader = config_loader self._config_store = config_store @@ -33,6 +36,8 @@ def __init__( self._mcp_tool_loader = mcp_tool_loader self._tracing_provider = tracing_provider self._prompt_manager = prompt_manager + self._stream_idle_timeout = stream_idle_timeout + self._invoke_timeout = invoke_timeout self._runners: dict[str, AgentRunner] = {} self._lock = asyncio.Lock() @@ -49,20 +54,26 @@ async def get_runner(self, agent_name: str) -> AgentRunner: AgentNotFoundError: If no config exists for this agent. """ if agent_name in self._runners: - logger.info("Agent '%s' loaded from cache", agent_name) + logger.info(LogMessage.AGENT_CACHE_HIT, agent_name) return self._runners[agent_name] async with self._lock: if agent_name in self._runners: return self._runners[agent_name] - logger.info("Building agent '%s' from persistent store", agent_name) + logger.info(LogMessage.AGENT_BUILDING, agent_name) yaml_content = await self._config_store.get(agent_name) config = self._config_loader.load_from_string(yaml_content) graph, response_format_model = await create_agent_from_config(config, self._mcp_tool_loader, self._prompt_manager) - runner = DeepAgentRunner(graph, tracing_provider=self._tracing_provider, response_format_model=response_format_model) + runner = DeepAgentRunner( + graph, + tracing_provider=self._tracing_provider, + response_format_model=response_format_model, + stream_idle_timeout=self._stream_idle_timeout, + invoke_timeout=self._invoke_timeout, + ) self._runners[agent_name] = runner - logger.info("Agent '%s' ready and cached", agent_name) + logger.info(LogMessage.AGENT_CACHED, agent_name) return runner async def list_agents(self) -> list[str]: @@ -74,9 +85,9 @@ async def invalidate(self, agent_name: str) -> None: """Remove cached runner for the given agent, forcing rebuild on next access.""" async with self._lock: self._runners.pop(agent_name, None) - logger.info("Invalidated cached agent '%s'", agent_name) + logger.info(LogMessage.AGENT_CACHE_INVALIDATED, agent_name) async def close(self) -> None: """Clear all cached runners.""" - logger.info("Closing persistent registry, clearing %d cached agents", len(self._runners)) + logger.info(LogMessage.REGISTRY_CLOSING, len(self._runners)) self._runners.clear() diff --git a/src/infrastructure/postgres_repository/adapter.py b/src/infrastructure/postgres_repository/adapter.py index bb2999b..fb78287 100644 --- a/src/infrastructure/postgres_repository/adapter.py +++ b/src/infrastructure/postgres_repository/adapter.py @@ -5,7 +5,10 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession from src.domain.entities.agent_config_metadata import AgentConfigMetadata -from src.domain.exceptions import AgentNotFoundError, StorageError +from src.domain.errors.agent import AgentNotFoundError +from src.domain.errors.messages import ErrorMessage +from src.domain.errors.storage import StorageError +from src.domain.logging.messages import LogMessage from src.domain.ports.agent_config_repository import AgentConfigRepository from src.infrastructure.database.models.agent_config import AgentConfigModel @@ -54,9 +57,11 @@ async def save(self, metadata: AgentConfigMetadata) -> None: ) await session.merge(model) await session.commit() - logger.info("Saved agent config metadata '%s'", metadata.name) + logger.info(LogMessage.AGENT_CONFIG_METADATA_SAVED, metadata.name) except SQLAlchemyError as e: - raise StorageError(f"Failed to save agent config metadata '{metadata.name}': {e}") from e + raise StorageError( + ErrorMessage.STORAGE_FAILED_SAVE_AGENT_CONFIG.format(name=metadata.name, error=e) + ) from e async def get(self, name: str) -> AgentConfigMetadata: """Retrieve metadata by agent name. @@ -75,12 +80,14 @@ async def get(self, name: str) -> AgentConfigMetadata: try: model = await session.get(AgentConfigModel, name) if model is None: - raise AgentNotFoundError(f"Agent config metadata not found: {name}") + raise AgentNotFoundError(ErrorMessage.AGENT_CONFIG_NOT_FOUND.format(name=name)) return _model_to_metadata(model) except AgentNotFoundError: raise except SQLAlchemyError as e: - raise StorageError(f"Failed to get agent config metadata '{name}': {e}") from e + raise StorageError( + ErrorMessage.STORAGE_FAILED_GET_AGENT_CONFIG.format(name=name, error=e) + ) from e async def list_all(self) -> list[AgentConfigMetadata]: """List all agent configuration metadata. @@ -97,7 +104,9 @@ async def list_all(self) -> list[AgentConfigMetadata]: models = result.scalars().all() return [_model_to_metadata(m) for m in models] except SQLAlchemyError as e: - raise StorageError(f"Failed to list agent config metadata: {e}") from e + raise StorageError( + ErrorMessage.STORAGE_FAILED_LIST_AGENT_CONFIG.format(error=e) + ) from e async def delete(self, name: str) -> None: """Delete metadata by agent name. @@ -113,14 +122,16 @@ async def delete(self, name: str) -> None: try: model = await session.get(AgentConfigModel, name) if model is None: - raise AgentNotFoundError(f"Agent config metadata not found: {name}") + raise AgentNotFoundError(ErrorMessage.AGENT_CONFIG_NOT_FOUND.format(name=name)) await session.delete(model) await session.commit() - logger.info("Deleted agent config metadata '%s'", name) + logger.info(LogMessage.AGENT_CONFIG_METADATA_DELETED, name) except AgentNotFoundError: raise except SQLAlchemyError as e: - raise StorageError(f"Failed to delete agent config metadata '{name}': {e}") from e + raise StorageError( + ErrorMessage.STORAGE_FAILED_DELETE_AGENT_CONFIG.format(name=name, error=e) + ) from e async def exists(self, name: str) -> bool: """Check whether metadata exists for the given agent name. @@ -139,4 +150,6 @@ async def exists(self, name: str) -> bool: model = await session.get(AgentConfigModel, name) return model is not None except SQLAlchemyError as e: - raise StorageError(f"Failed to check existence of agent config '{name}': {e}") from e + raise StorageError( + ErrorMessage.STORAGE_FAILED_EXISTS_AGENT_CONFIG.format(name=name, error=e) + ) from e diff --git a/src/infrastructure/postgres_thread/adapter.py b/src/infrastructure/postgres_thread/adapter.py index 66bce21..17ada5e 100644 --- a/src/infrastructure/postgres_thread/adapter.py +++ b/src/infrastructure/postgres_thread/adapter.py @@ -9,7 +9,9 @@ from src.domain.entities.message import Message, MessageRole, MessageStatus from src.domain.entities.thread import Thread -from src.domain.exceptions import StorageError, ThreadNotFoundError +from src.domain.errors.messages import ErrorMessage +from src.domain.errors.storage import StorageError +from src.domain.errors.thread import ThreadNotFoundError from src.domain.ports.thread_repository import ThreadRepository from src.infrastructure.database.models.thread import MessageModel, ThreadModel @@ -97,7 +99,7 @@ async def create(self, agent_name: str) -> Thread: updated_at=model.updated_at, ) except SQLAlchemyError as e: - raise StorageError(f"Failed to create thread: {e}") from e + raise StorageError(ErrorMessage.THREAD_FAILED_CREATE.format(error=e)) from e async def get(self, thread_id: str) -> Thread: """Retrieve a thread by its ID. @@ -116,12 +118,12 @@ async def get(self, thread_id: str) -> Thread: try: model = await session.get(ThreadModel, thread_id, options=[selectinload(ThreadModel.messages)]) if model is None: - raise ThreadNotFoundError(f"Thread not found: {thread_id}") + raise ThreadNotFoundError(ErrorMessage.THREAD_NOT_FOUND.format(thread_id=thread_id)) return _model_to_thread(model) except ThreadNotFoundError: raise except SQLAlchemyError as e: - raise StorageError(f"Failed to get thread {thread_id}: {e}") from e + raise StorageError(ErrorMessage.THREAD_FAILED_GET.format(thread_id=thread_id, error=e)) from e async def list_all(self) -> list[Thread]: """List all conversation threads. @@ -142,7 +144,7 @@ async def list_all(self) -> list[Thread]: models = result.scalars().all() return [_model_to_thread(model) for model in models] except SQLAlchemyError as e: - raise StorageError(f"Failed to list threads: {e}") from e + raise StorageError(ErrorMessage.THREAD_FAILED_LIST.format(error=e)) from e async def delete(self, thread_id: str) -> None: """Delete a thread and all its messages. @@ -158,13 +160,13 @@ async def delete(self, thread_id: str) -> None: try: model = await session.get(ThreadModel, thread_id) if model is None: - raise ThreadNotFoundError(f"Thread not found: {thread_id}") + raise ThreadNotFoundError(ErrorMessage.THREAD_NOT_FOUND.format(thread_id=thread_id)) await session.delete(model) await session.commit() except ThreadNotFoundError: raise except SQLAlchemyError as e: - raise StorageError(f"Failed to delete thread {thread_id}: {e}") from e + raise StorageError(ErrorMessage.THREAD_FAILED_DELETE.format(thread_id=thread_id, error=e)) from e async def add_message(self, thread_id: str, message: Message) -> Thread: """Add a message to an existing thread. @@ -184,7 +186,7 @@ async def add_message(self, thread_id: str, message: Message) -> Thread: try: thread_model = await session.get(ThreadModel, thread_id) if thread_model is None: - raise ThreadNotFoundError(f"Thread not found: {thread_id}") + raise ThreadNotFoundError(ErrorMessage.THREAD_NOT_FOUND.format(thread_id=thread_id)) msg_model = MessageModel( id=str(uuid4()), @@ -213,4 +215,4 @@ async def add_message(self, thread_id: str, message: Message) -> Thread: except ThreadNotFoundError: raise except SQLAlchemyError as e: - raise StorageError(f"Failed to add message to thread {thread_id}: {e}") from e + raise StorageError(ErrorMessage.THREAD_FAILED_ADD_MESSAGE.format(thread_id=thread_id, error=e)) from e diff --git a/src/infrastructure/prompt_management/phoenix_prompt_adapter.py b/src/infrastructure/prompt_management/phoenix_prompt_adapter.py index a1c5b0b..b9a326e 100644 --- a/src/infrastructure/prompt_management/phoenix_prompt_adapter.py +++ b/src/infrastructure/prompt_management/phoenix_prompt_adapter.py @@ -1,17 +1,20 @@ import logging import os -from functools import wraps -from typing import TypeVar, Callable, Any -import asyncio import httpx from cachetools import TTLCache, cached from phoenix.client import Client -from phoenix.client.client import _update_headers, _WrappedClient from phoenix.client.resources.prompts import PromptVersion as PhoenixPromptVersion -from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type +from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential from src.domain.entities.prompt import Prompt, PromptVersion +from src.domain.errors.messages import ErrorMessage +from src.domain.errors.prompt import ( + PromptAlreadyExistsError, + PromptManagerUnavailableError, + PromptNotFoundError, +) +from src.domain.logging.messages import LogMessage from src.domain.ports.prompt_manager import PromptManager logger = logging.getLogger(__name__) @@ -28,24 +31,33 @@ reraise=True, ) - -class PhoenixUnavailableError(RuntimeError): - """Raised when Phoenix is unreachable or returns a server error.""" - pass +# Domain exceptions that must propagate without being wrapped. +_PROPAGATED_ERRORS = (PromptNotFoundError, PromptManagerUnavailableError, PromptAlreadyExistsError) def _wrap_phoenix_error(operation: str, identifier: str, e: Exception) -> Exception: - """Convert raw Phoenix/httpx exceptions to meaningful domain errors.""" + """Convert raw Phoenix/httpx exceptions to domain exceptions. + + Infrastructure never defines its own error classes: it only raises the + domain exceptions declared in ``src/domain/exceptions.py``. + """ if isinstance(e, (httpx.TimeoutException, httpx.ConnectError)): - return PhoenixUnavailableError( - f"Phoenix unavailable during '{operation}' for '{identifier}': {e}" + return PromptManagerUnavailableError( + ErrorMessage.PROMPT_MANAGER_UNAVAILABLE.format( + operation=operation, identifier=identifier, error=e + ) ) if isinstance(e, httpx.HTTPStatusError): - if e.response.status_code == 404: - return ValueError(f"Prompt not found: {identifier}") - if e.response.status_code >= 500: - return PhoenixUnavailableError( - f"Phoenix server error ({e.response.status_code}) during '{operation}' for '{identifier}'" + status_code = e.response.status_code + if status_code == 404: + return PromptNotFoundError(ErrorMessage.PROMPT_NOT_FOUND.format(identifier=identifier)) + if status_code == 409: + return PromptAlreadyExistsError(ErrorMessage.PROMPT_ALREADY_EXISTS.format(identifier=identifier)) + if status_code >= 500: + return PromptManagerUnavailableError( + ErrorMessage.PROMPT_MANAGER_SERVER_ERROR.format( + status_code=status_code, operation=operation, identifier=identifier + ) ) return e @@ -72,9 +84,9 @@ def __init__( timeout=httpx.Timeout(connect=10.0, read=timeout, write=timeout, pool=10.0), ), ) - logger.info("PhoenixPromptManagerProvider initialized base_url=%s timeout=%ss", base_url, timeout) + logger.info(LogMessage.PHOENIX_PROMPT_PROVIDER_INITIALIZED, base_url, timeout) except Exception: - logger.exception("Failed to initialize Phoenix client") + logger.exception(LogMessage.PHOENIX_CLIENT_INIT_FAILED) self._client = None @_phoenix_retry @@ -85,7 +97,7 @@ async def get_prompt( tag: str | None = None, ) -> Prompt: if not self._client: - raise PhoenixUnavailableError("Phoenix client not initialized") + raise PromptManagerUnavailableError(ErrorMessage.PROMPT_MANAGER_NOT_INITIALIZED) try: prompt_obj: PhoenixPromptVersion = self._client.prompts.get( prompt_identifier=identifier, @@ -93,16 +105,16 @@ async def get_prompt( tag=tag, ) if not prompt_obj: - raise ValueError(f"Prompt not found: {identifier}") - + raise PromptNotFoundError(ErrorMessage.PROMPT_NOT_FOUND.format(identifier=identifier)) + tags = self._client.prompts.tags.list(prompt_version_id=prompt_obj.id) if prompt_obj and prompt_obj.id else [] - logger.info("Retrieved prompt content for '%s' (version: %s, tags: %s)", identifier, version_id, [t["name"] for t in tags]) + logger.info(LogMessage.PROMPT_RETRIEVED, identifier, version_id, [t["name"] for t in tags]) return self._to_domain_prompt(prompt_obj, identifier=identifier, description=prompt_obj._description, tags=[t["name"] for t in tags]) - except (ValueError, PhoenixUnavailableError): + except _PROPAGATED_ERRORS: raise - except Exception: - logger.exception("Error getting prompt '%s'", identifier) + except Exception as e: + logger.exception(LogMessage.PROMPT_GET_ERROR, identifier) raise _wrap_phoenix_error("get_prompt", identifier, e) from e @cached(cache=TTLCache(maxsize=10, ttl=300)) @@ -114,7 +126,7 @@ async def get_prompt_content( tag: str | None = None, ) -> dict[str, str]: if not self._client: - raise PhoenixUnavailableError("Phoenix client not initialized") + raise PromptManagerUnavailableError(ErrorMessage.PROMPT_MANAGER_NOT_INITIALIZED) try: prompt_obj = self._client.prompts.get( prompt_identifier=identifier, @@ -123,15 +135,15 @@ async def get_prompt_content( ) tags = self._client.prompts.tags.list(prompt_version_id=prompt_obj.id) if prompt_obj and prompt_obj.id else [] - logger.info("Retrieved prompt content for '%s' (version: %s, tags: %s)", identifier, version_id, [t["name"] for t in tags]) + logger.info(LogMessage.PROMPT_RETRIEVED, identifier, version_id, [t["name"] for t in tags]) domain = self._to_domain_prompt(prompt_obj, identifier=identifier, tags=[t["name"] for t in tags]) messages = domain.current_version.content return messages[0] if messages else {} - except (ValueError, PhoenixUnavailableError): + except _PROPAGATED_ERRORS: raise - except Exception: - logger.exception("Error getting prompt content '%s'", identifier) + except Exception as e: + logger.exception(LogMessage.PROMPT_GET_CONTENT_ERROR, identifier) raise _wrap_phoenix_error("get_prompt_content", identifier, e) from e @_phoenix_retry @@ -145,7 +157,7 @@ async def create_prompt( metadata: dict | None = None, ) -> PhoenixPromptVersion: if not self._client: - raise PhoenixUnavailableError("Phoenix client not initialized") + raise PromptManagerUnavailableError(ErrorMessage.PROMPT_MANAGER_NOT_INITIALIZED) try: prompt_obj = self._client.prompts.create( name=identifier, @@ -161,14 +173,14 @@ async def create_prompt( name=tag, ) except Exception as tag_error: - logger.warning("Failed to add tag '%s' to '%s': %s", tag, identifier, tag_error) + logger.warning(LogMessage.PROMPT_TAG_ADD_FAILED, tag, identifier, tag_error) - logger.info("Created prompt '%s'", identifier) + logger.info(LogMessage.PROMPT_VERSION_CREATED, identifier) return prompt_obj - except (ValueError, PhoenixUnavailableError): + except _PROPAGATED_ERRORS: raise - except Exception: - logger.exception("Error creating prompt '%s'", identifier) + except Exception as e: + logger.exception(LogMessage.PROMPT_CREATE_ERROR, identifier) raise _wrap_phoenix_error("create_prompt", identifier, e) from e @_phoenix_retry @@ -181,10 +193,10 @@ async def update_prompt( metadata: dict | None = None, ) -> PhoenixPromptVersion: if not self._client: - raise PhoenixUnavailableError("Phoenix client not initialized") + raise PromptManagerUnavailableError(ErrorMessage.PROMPT_MANAGER_NOT_INITIALIZED) if description is not None: logger.warning( - "Phoenix does not support updating description on existing prompts — description change ignored for '%s'", + LogMessage.PHOENIX_DESC_UPDATE_UNSUPPORTED, identifier ) try: @@ -195,25 +207,27 @@ async def update_prompt( prompt_description=description or current.description, prompt_metadata=metadata or current.metadata, ) - logger.info("Updated prompt '%s'", identifier) + logger.info(LogMessage.PROMPT_VERSION_UPDATED, identifier) return updated - except (ValueError, PhoenixUnavailableError): + except _PROPAGATED_ERRORS: raise - except Exception: - logger.exception("Error updating prompt '%s'", identifier) + except Exception as e: + logger.exception(LogMessage.PROMPT_UPDATE_ERROR, identifier) raise _wrap_phoenix_error("update_prompt", identifier, e) from e async def add_tag(self, identifier: str, tag: str) -> None: if not self._client: - raise PhoenixUnavailableError("Phoenix client not initialized") + raise PromptManagerUnavailableError(ErrorMessage.PROMPT_MANAGER_NOT_INITIALIZED) try: self._client.prompts.tags.create( prompt_version_id=identifier, name=tag, ) - logger.info("Added tag '%s' to prompt '%s'", tag, identifier) - except Exception: - logger.exception("Error adding tag '%s' to '%s'", tag, identifier) + logger.info(LogMessage.PROMPT_TAG_ADDED, tag, identifier) + except _PROPAGATED_ERRORS: + raise + except Exception as e: + logger.exception(LogMessage.PROMPT_TAG_ADD_ERROR, tag, identifier) raise _wrap_phoenix_error("add_tag", identifier, e) from e def _to_domain_prompt( diff --git a/src/infrastructure/tracing/phoenix_adapter.py b/src/infrastructure/tracing/phoenix_adapter.py index 1c96327..d3f7ed8 100644 --- a/src/infrastructure/tracing/phoenix_adapter.py +++ b/src/infrastructure/tracing/phoenix_adapter.py @@ -5,6 +5,7 @@ from openinference.instrumentation.langchain import LangChainInstrumentor from opentelemetry import trace +from src.domain.logging.messages import LogMessage from src.domain.ports.tracing_provider import TracingProvider logger = logging.getLogger(__name__) @@ -27,7 +28,7 @@ def __init__( endpoint = f"{endpoint.rstrip('/')}/v1/traces" logger.info( - "Initializing PhoenixTracingProvider with endpoint=%s, project_name=%s", + LogMessage.PHOENIX_PROVIDER_INIT, endpoint, project_name, ) @@ -47,7 +48,7 @@ def __init__( # Store tracer provider for flush/shutdown self._tracer_provider = trace.get_tracer_provider() self._instrumented = True - logger.info("Phoenix tracing initialized successfully") + logger.info(LogMessage.PHOENIX_TRACING_INITIALIZED) def get_callbacks(self) -> list[Any]: """Return an empty list since Phoenix uses OpenTelemetry auto-instrumentation.""" @@ -62,9 +63,9 @@ async def flush(self) -> None: timeout_millis = 30000 if hasattr(self._tracer_provider, "force_flush"): self._tracer_provider.force_flush(timeout_millis=timeout_millis) - logger.info("Flushed pending spans to Phoenix") + logger.info(LogMessage.PHOENIX_SPANS_FLUSHED) except Exception: - logger.exception("Error flushing spans to Phoenix") + logger.exception(LogMessage.PHOENIX_FLUSH_FAILED) async def shutdown(self) -> None: """Shutdown the tracer provider and flush remaining spans.""" @@ -75,6 +76,6 @@ async def shutdown(self) -> None: await self.flush() if hasattr(self._tracer_provider, "shutdown"): self._tracer_provider.shutdown() - logger.info("Phoenix tracing provider shutdown complete") + logger.info(LogMessage.PHOENIX_TRACING_SHUTDOWN) except Exception: - logger.exception("Error shutting down tracer provider") + logger.exception(LogMessage.TRACER_SHUTDOWN_FAILED) diff --git a/src/infrastructure/yaml_config/adapter.py b/src/infrastructure/yaml_config/adapter.py index e81e6c4..bec5433 100644 --- a/src/infrastructure/yaml_config/adapter.py +++ b/src/infrastructure/yaml_config/adapter.py @@ -4,7 +4,9 @@ import yaml from src.domain.entities.agent_config import AgentConfig -from src.domain.exceptions import ConfigError, ConfigNotFoundError, ConfigValidationError +from src.domain.errors.config import ConfigError, ConfigNotFoundError, ConfigValidationError +from src.domain.errors.messages import ErrorMessage +from src.domain.logging.messages import LogMessage from src.domain.ports.agent_config_loader import AgentConfigLoader logger = logging.getLogger(__name__) @@ -18,12 +20,12 @@ def _parse_yaml(content: str, source: str) -> dict: try: raw = yaml.safe_load(content) except yaml.YAMLError as e: - logger.exception(f"Invalid YAML from {source}") - raise ConfigError(f"Invalid YAML from {source}: {e}") from e + logger.exception(LogMessage.YAML_PARSE_FAILED, source) + raise ConfigError(ErrorMessage.YAML_INVALID.format(source=source, error=e)) from e if not isinstance(raw, dict): - logger.error(f"YAML from {source} must contain a YAML mapping, not {type(raw).__name__}") - raise ConfigError(f"YAML from {source} must contain a YAML mapping, not {type(raw).__name__}") + logger.error(LogMessage.YAML_NOT_MAPPING_LOG, source, type(raw).__name__) + raise ConfigError(ErrorMessage.YAML_NOT_MAPPING.format(source=source, type=type(raw).__name__)) return raw @@ -33,25 +35,25 @@ def _validate(raw: dict, source: str) -> AgentConfig: return AgentConfig.model_validate(raw) except Exception as e: if hasattr(e, "errors"): - logger.exception(f"Validation error from {source}") + logger.exception(LogMessage.YAML_VALIDATION_FAILED, source) raise ConfigValidationError(e.errors()) from e - logger.exception(f"Validation error from {source}") - raise ConfigError(f"Validation error: {e}") from e + logger.exception(LogMessage.YAML_VALIDATION_FAILED, source) + raise ConfigError(ErrorMessage.YAML_VALIDATION_ERROR.format(error=e)) from e def load(self, config_path: str | Path) -> AgentConfig: path = Path(config_path) if not path.exists(): - logger.error(f"Config file not found: {path}") - raise ConfigNotFoundError(f"Config file not found: {path}") + logger.error(LogMessage.CONFIG_FILE_NOT_FOUND_LOG, path) + raise ConfigNotFoundError(ErrorMessage.YAML_CONFIG_NOT_FOUND.format(path=path)) raw = self._parse_yaml(path.read_text(encoding="utf-8"), str(path)) if raw.get("system_prompt_file"): prompt_path = path.parent / raw["system_prompt_file"] if not prompt_path.exists(): - logger.error(f"Prompt file not found: {prompt_path}") - raise ConfigNotFoundError(f"Prompt file not found: {prompt_path}") + logger.error(LogMessage.PROMPT_FILE_NOT_FOUND_LOG, prompt_path) + raise ConfigNotFoundError(ErrorMessage.YAML_PROMPT_FILE_NOT_FOUND.format(path=prompt_path)) raw["system_prompt"] = prompt_path.read_text(encoding="utf-8") raw.pop("system_prompt_file") @@ -59,16 +61,15 @@ def load(self, config_path: str | Path) -> AgentConfig: def load_from_string(self, yaml_content: str, source: str = "") -> AgentConfig: if not yaml_content or not yaml_content.strip(): - logger.error("Empty YAML content from %s", source) - raise ConfigError(f"Empty YAML content from {source}") + logger.error(LogMessage.YAML_EMPTY_LOG, source) + raise ConfigError(ErrorMessage.YAML_EMPTY.format(source=source)) raw = self._parse_yaml(yaml_content, source) if raw.get("system_prompt_file"): - logger.error("system_prompt_file is not allowed in string-loaded YAML from %s", source) + logger.error(LogMessage.YAML_SYSTEM_PROMPT_FILE_DISALLOWED, source) raise ConfigError( - f"system_prompt_file is not allowed in string-loaded YAML from {source}. " - "Inline the prompt in system_prompt instead." + ErrorMessage.YAML_SYSTEM_PROMPT_FILE_DISALLOWED.format(source=source) ) return self._validate(raw, source) diff --git a/src/main.py b/src/main.py index 77718f0..4f90e1e 100644 --- a/src/main.py +++ b/src/main.py @@ -9,15 +9,37 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse +from src.application.routes.agents import router as agents_router +from src.application.routes.chat import router as chat_router +from src.application.routes.health import router as health_router +from src.application.routes.prompt import router as prompt_router +from src.application.routes.threads import router as threads_router +from src.application.routes.websocket import router as websocket_router from src.config import Settings +from src.dependencies import ( + close_persistence, + init_persistence, + mcp_tool_loader, + tracing_provider, +) +from src.domain.errors.agent import AgentConfigAlreadyExistsError, AgentError, AgentNotFoundError +from src.domain.errors.base import DomainError +from src.domain.errors.config import ConfigError, ConfigNotFoundError, ConfigValidationError +from src.domain.errors.hitl import InvalidHitlActionError +from src.domain.errors.mcp import McpError +from src.domain.errors.prompt import ( + PromptAlreadyExistsError, + PromptManagerUnavailableError, + PromptNotFoundError, +) +from src.domain.errors.storage import StorageError +from src.domain.errors.thread import ThreadNotFoundError +from src.domain.logging.messages import LogMessage +from src.infrastructure.logging import RequestIdMiddleware, configure_logging settings = Settings() -logging.basicConfig( - level=settings.uvicorn_log_level.upper(), - format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", -) +configure_logging(settings) logger = logging.getLogger(__name__) @@ -35,34 +57,32 @@ def _run_alembic_upgrade() -> None: @asynccontextmanager async def lifespan(_app: FastAPI): - logging.getLogger().setLevel(settings.uvicorn_log_level.upper()) - - logger.info("Application startup initiated") + logger.info(LogMessage.APP_STARTUP_INITIATED) try: await init_persistence() - logger.info("Persistence initialized") + logger.info(LogMessage.APP_PERSISTENCE_INITIALIZED) except Exception: - logger.exception("Failed to initialize persistence, falling back to filesystem registry") - logger.info("Application startup complete") + logger.exception(LogMessage.APP_PERSISTENCE_INIT_FAILED) + logger.info(LogMessage.APP_STARTUP_COMPLETE) yield - logger.info("Application shutdown initiated") + logger.info(LogMessage.APP_SHUTDOWN_INITIATED) try: await close_persistence() - logger.info("Persistence closed") + logger.info(LogMessage.APP_PERSISTENCE_CLOSED) except Exception: - logger.exception("Error closing persistence") + logger.exception(LogMessage.APP_PERSISTENCE_CLOSE_FAILED) try: await mcp_tool_loader.close() - logger.info("MCP tool loader closed") + logger.info(LogMessage.APP_MCP_LOADER_CLOSED) except Exception: - logger.exception("Error closing MCP tool loader") + logger.exception(LogMessage.APP_MCP_LOADER_CLOSE_FAILED) try: await tracing_provider.flush() await tracing_provider.shutdown() - logger.info("Tracing provider shut down") + logger.info(LogMessage.APP_TRACING_SHUTDOWN) except Exception: - logger.exception("Error shutting down tracing provider") - logger.info("Application shutdown complete") + logger.exception(LogMessage.APP_TRACING_SHUTDOWN_FAILED) + logger.info(LogMessage.APP_SHUTDOWN_COMPLETE) app = FastAPI( @@ -72,6 +92,7 @@ async def lifespan(_app: FastAPI): lifespan=lifespan, ) +app.add_middleware(RequestIdMiddleware) app.add_middleware( CORSMiddleware, allow_origins=settings.allowed_origins, @@ -80,30 +101,6 @@ async def lifespan(_app: FastAPI): allow_headers=["*"], ) -from src.application.routes.agents import router as agents_router -from src.application.routes.chat import router as chat_router -from src.application.routes.health import router as health_router -from src.application.routes.prompt import router as prompt_router -from src.application.routes.threads import router as threads_router -from src.application.routes.websocket import router as websocket_router -from src.dependencies import ( - close_persistence, - init_persistence, - mcp_tool_loader, - tracing_provider, -) -from src.domain.exceptions import ( - AgentConfigAlreadyExistsError, - AgentError, - AgentNotFoundError, - ConfigError, - ConfigNotFoundError, - ConfigValidationError, - DomainError, - StorageError, - ThreadNotFoundError, -) - app.include_router(health_router) app.include_router(threads_router) app.include_router(chat_router) @@ -112,73 +109,119 @@ async def lifespan(_app: FastAPI): app.include_router(prompt_router) -@app.exception_handler(AgentConfigAlreadyExistsError) +def _error_response(exc: DomainError) -> JSONResponse: + """Build a JSON response from any domain error. + + Each domain error carries its own ``status_code`` (an :class:`ErrorCode`) + and ``detail`` text, so the response shape is uniform for every type. + :class:`ConfigValidationError` additionally exposes structured ``errors``. + """ + content: dict = {"detail": exc.detail} + if isinstance(exc, ConfigValidationError): + content["errors"] = exc.errors + return JSONResponse(status_code=int(exc.status_code), content=content) + + async def agent_config_already_exists_handler(_request: Request, exc: AgentConfigAlreadyExistsError) -> JSONResponse: - logger.warning("Agent config already exists: %s", exc) - return JSONResponse(status_code=409, content={"detail": str(exc)}) + logger.warning(LogMessage.LOG_AGENT_CONFIG_ALREADY_EXISTS, exc.detail) + return _error_response(exc) -@app.exception_handler(StorageError) async def storage_error_handler(_request: Request, exc: StorageError) -> JSONResponse: - logger.error("Storage error: %s", exc) - return JSONResponse(status_code=503, content={"detail": str(exc)}) + logger.error(LogMessage.LOG_STORAGE_ERROR, exc.detail) + return _error_response(exc) -@app.exception_handler(AgentNotFoundError) async def agent_not_found_handler(_request: Request, exc: AgentNotFoundError) -> JSONResponse: - logger.warning("Agent not found: %s", exc) - return JSONResponse(status_code=404, content={"detail": str(exc)}) + logger.warning(LogMessage.LOG_AGENT_NOT_FOUND, exc.detail) + return _error_response(exc) -@app.exception_handler(ConfigNotFoundError) async def config_not_found_handler(_request: Request, exc: ConfigNotFoundError) -> JSONResponse: - logger.warning("Config not found: %s", exc) - return JSONResponse(status_code=404, content={"detail": str(exc)}) + logger.warning(LogMessage.LOG_CONFIG_NOT_FOUND, exc.detail) + return _error_response(exc) -@app.exception_handler(ThreadNotFoundError) async def thread_not_found_handler(_request: Request, exc: ThreadNotFoundError) -> JSONResponse: - logger.warning("Thread not found: %s", exc) - return JSONResponse(status_code=404, content={"detail": str(exc)}) + logger.warning(LogMessage.LOG_THREAD_NOT_FOUND, exc.detail) + return _error_response(exc) + + +async def prompt_not_found_handler(_request: Request, exc: PromptNotFoundError) -> JSONResponse: + logger.warning(LogMessage.LOG_PROMPT_NOT_FOUND, exc.detail) + return _error_response(exc) + + +async def prompt_already_exists_handler(_request: Request, exc: PromptAlreadyExistsError) -> JSONResponse: + logger.warning(LogMessage.LOG_PROMPT_ALREADY_EXISTS, exc.detail) + return _error_response(exc) + + +async def prompt_manager_unavailable_handler(_request: Request, exc: PromptManagerUnavailableError) -> JSONResponse: + logger.error(LogMessage.LOG_PROMPT_MANAGER_UNAVAILABLE, exc.detail) + return _error_response(exc) + + +async def invalid_hitl_action_handler(_request: Request, exc: InvalidHitlActionError) -> JSONResponse: + logger.warning(LogMessage.LOG_INVALID_HITL_ACTION, exc.detail) + return _error_response(exc) -@app.exception_handler(ConfigValidationError) async def config_validation_handler(_request: Request, exc: ConfigValidationError) -> JSONResponse: - logger.error("Config validation error: %s errors=%s", exc, exc.errors) - return JSONResponse(status_code=422, content={"detail": str(exc), "errors": exc.errors}) + logger.error(LogMessage.LOG_CONFIG_VALIDATION_ERROR, exc.detail, exc.errors) + return _error_response(exc) -@app.exception_handler(ConfigError) async def config_error_handler(_request: Request, exc: ConfigError) -> JSONResponse: - logger.error("Config error: %s", exc) - return JSONResponse(status_code=400, content={"detail": str(exc)}) + logger.error(LogMessage.LOG_CONFIG_ERROR, exc.detail) + return _error_response(exc) -@app.exception_handler(AgentError) async def agent_error_handler(_request: Request, exc: AgentError) -> JSONResponse: - logger.error("Agent error: %s", exc) - return JSONResponse(status_code=502, content={"detail": str(exc)}) + logger.error(LogMessage.LOG_AGENT_ERROR, exc.detail) + return _error_response(exc) + + +async def mcp_error_handler(_request: Request, exc: McpError) -> JSONResponse: + logger.error(LogMessage.LOG_MCP_ERROR, exc.detail) + return _error_response(exc) -@app.exception_handler(DomainError) async def domain_error_handler(_request: Request, exc: DomainError) -> JSONResponse: - logger.error("Domain error: %s", exc) - return JSONResponse(status_code=500, content={"detail": str(exc)}) + logger.error(LogMessage.LOG_UNHANDLED_DOMAIN_ERROR, exc.detail) + return _error_response(exc) + + +# Register one handler per domain error type explicitly. Each handler reads the +# exception's own status_code/detail, so no separate error->HTTP mapping table +# is required. Most-specific types are registered first. +app.add_exception_handler(ConfigNotFoundError, config_not_found_handler) +app.add_exception_handler(ConfigValidationError, config_validation_handler) +app.add_exception_handler(ConfigError, config_error_handler) +app.add_exception_handler(ThreadNotFoundError, thread_not_found_handler) +app.add_exception_handler(AgentNotFoundError, agent_not_found_handler) +app.add_exception_handler(AgentConfigAlreadyExistsError, agent_config_already_exists_handler) +app.add_exception_handler(AgentError, agent_error_handler) +app.add_exception_handler(InvalidHitlActionError, invalid_hitl_action_handler) +app.add_exception_handler(StorageError, storage_error_handler) +app.add_exception_handler(McpError, mcp_error_handler) +app.add_exception_handler(PromptNotFoundError, prompt_not_found_handler) +app.add_exception_handler(PromptAlreadyExistsError, prompt_already_exists_handler) +app.add_exception_handler(PromptManagerUnavailableError, prompt_manager_unavailable_handler) +app.add_exception_handler(DomainError, domain_error_handler) def run_fastapi(): - logger.info("Running database migrations...") + logger.info(LogMessage.APP_MIGRATIONS_RUNNING) _run_alembic_upgrade() - logger.info("Database migrations completed") + logger.info(LogMessage.APP_MIGRATIONS_DONE) uvicorn.run( - app, + "main:app", host=settings.host, port=settings.port, - log_level=settings.uvicorn_log_level, - access_log=True, ) if __name__ == "__main__": - run_fastapi() \ No newline at end of file + run_fastapi() diff --git a/tests/fixtures/in_memory_thread_repository.py b/tests/fixtures/in_memory_thread_repository.py index c595c7b..e145846 100644 --- a/tests/fixtures/in_memory_thread_repository.py +++ b/tests/fixtures/in_memory_thread_repository.py @@ -9,7 +9,7 @@ from src.domain.entities.message import Message from src.domain.entities.thread import Thread -from src.domain.exceptions import ThreadNotFoundError +from src.domain.errors.thread import ThreadNotFoundError from src.domain.ports.thread_repository import ThreadRepository logger = logging.getLogger("composable-agents") diff --git a/tests/integration/test_deepagent_real_graph.py b/tests/integration/test_deepagent_real_graph.py new file mode 100644 index 0000000..b9815a1 --- /dev/null +++ b/tests/integration/test_deepagent_real_graph.py @@ -0,0 +1,87 @@ +"""Integration tests for the REAL deepagents graph. + +Unlike the unit tests (which mock create_deep_agent and the LangGraph), these +tests build an actual ``create_deep_agent`` graph with a deterministic fake +chat model and exercise the full agent -> tool -> agent -> final cycle. + +Purpose: catch breaking changes in the ``deepagents`` SDK (create_deep_agent +signature, tool execution, graph structure) that the mocked unit tests cannot +detect. The fake model (GenericFakeChatModel) requires no API key and is fully +deterministic. +""" + +from langchain_core.language_models.fake_chat_models import GenericFakeChatModel +from langchain_core.messages import AIMessage, HumanMessage +from langchain_core.tools import tool +from langgraph.checkpoint.memory import MemorySaver + +from src.infrastructure.deepagent.factory import create_deep_agent + + +class _FakeToolCallingModel(GenericFakeChatModel): + """GenericFakeChatModel that accepts bind_tools (deepagents calls it). + + The returned tool calls are predetermined, so bind_tools just returns self. + """ + + def bind_tools(self, tools, **kwargs): # noqa: ARG002 + return self + + +def _fake_model() -> _FakeToolCallingModel: + """A deterministic chat model that issues one tool call then a final answer.""" + return _FakeToolCallingModel( + messages=iter( + [ + AIMessage( + content="", + tool_calls=[ + {"name": "echo", "args": {"text": "hello"}, "id": "call_1", "type": "tool_call"} + ], + ), + AIMessage(content="final answer"), + AIMessage(content="final answer"), + ] + ) + ) + + +@tool +def echo(text: str) -> str: + """Echo the provided text back.""" + return f"echoed: {text}" + + +class TestRealDeepAgentGraph: + def test_agent_executes_tool_then_final_answer(self): + """The graph must call the tool, ingest its result, then produce a final answer.""" + graph = create_deep_agent( + model=_fake_model(), + tools=[echo], + system_prompt="You are a helpful agent. Use the echo tool once then answer.", + checkpointer=MemorySaver(), + ) + + result = graph.invoke( + {"messages": [HumanMessage(content="echo hello please")]}, + config={"configurable": {"thread_id": "test-thread"}}, + ) + + messages = result["messages"] + # Human -> AI(tool_call) -> Tool(result) -> AI(final) + roles = [type(m).__name__ for m in messages] + assert "ToolMessage" in roles, f"Expected a ToolMessage in the cycle, got: {roles}" + + final = messages[-1] + assert isinstance(final, AIMessage) + assert final.content == "final answer" + + def test_graph_has_tools_node(self): + """The runner adapter relies on a 'tools' node existing on the compiled graph.""" + graph = create_deep_agent( + model=_fake_model(), + tools=[echo], + system_prompt="You are a helper.", + checkpointer=MemorySaver(), + ) + assert "tools" in graph.nodes, "deepagents graph must expose a 'tools' node" diff --git a/tests/unit/test_agent_crud.py b/tests/unit/test_agent_crud.py index 263ff03..c6bdecb 100644 --- a/tests/unit/test_agent_crud.py +++ b/tests/unit/test_agent_crud.py @@ -15,7 +15,8 @@ from src.application.use_cases.list_agent_configs import ListAgentConfigsUseCase from src.application.use_cases.update_agent_config import UpdateAgentConfigUseCase from src.domain.entities.agent_config_metadata import AgentConfigMetadata -from src.domain.exceptions import AgentConfigAlreadyExistsError, AgentNotFoundError, ConfigError +from src.domain.errors.agent import AgentConfigAlreadyExistsError, AgentNotFoundError +from src.domain.errors.config import ConfigError from src.domain.ports.agent_config_repository import AgentConfigRepository from src.domain.ports.agent_config_store import AgentConfigStore from src.domain.ports.agent_registry import AgentRegistry diff --git a/tests/unit/test_deep_agent_runner.py b/tests/unit/test_deep_agent_runner.py index 978cdb0..0c877b2 100644 --- a/tests/unit/test_deep_agent_runner.py +++ b/tests/unit/test_deep_agent_runner.py @@ -8,7 +8,7 @@ import pytest from src.domain.entities.message import MessageRole, MessageStatus -from src.domain.exceptions import AgentError +from src.domain.errors.agent import AgentError from src.infrastructure.deepagent.adapter import DeepAgentRunner diff --git a/tests/unit/test_deep_agent_runner_stream_with_message.py b/tests/unit/test_deep_agent_runner_stream_with_message.py index 1e103cc..4165932 100644 --- a/tests/unit/test_deep_agent_runner_stream_with_message.py +++ b/tests/unit/test_deep_agent_runner_stream_with_message.py @@ -9,7 +9,7 @@ from src.domain.entities.message import Message, MessageRole, MessageStatus from src.domain.entities.stream_event import StreamEventType -from src.domain.exceptions import AgentError +from src.domain.errors.agent import AgentError from src.infrastructure.deepagent.adapter import DeepAgentRunner @@ -180,3 +180,39 @@ async def _astream_error(_input, _config=None, _stream_mode=None): collected = [] async for event in runner.stream_with_message("thread-1", "hello"): collected.append(event) + + async def test_stream_idle_timeout_raises_agent_error(self): + """A graph that hangs between chunks (lost tool result) must abort via AgentError.""" + import asyncio + + mock_graph = AsyncMock() + + async def _astream_hang(_input, **_kwargs): + chunk = MagicMock() + chunk.content = "first" + chunk.type = "AIMessageChunk" + chunk.additional_kwargs = {} + yield chunk, MagicMock() + # Simulate a stuck graph: a tool result never arrives. + await asyncio.sleep(10) + + mock_graph.astream = _astream_hang + runner = DeepAgentRunner(mock_graph, stream_idle_timeout=0.05) + with pytest.raises(AgentError, match="idle"): + async for _event in runner.stream_with_message("thread-1", "hello"): + pass + + async def test_invoke_timeout_raises_agent_error(self): + """A non-streaming invoke that hangs must abort via AgentError.""" + import asyncio + + mock_graph = AsyncMock() + + async def _ainvoke_hang(_input, **_kwargs): + await asyncio.sleep(10) + return {"messages": []} + + mock_graph.ainvoke = _ainvoke_hang + runner = DeepAgentRunner(mock_graph, invoke_timeout=0.05) + with pytest.raises(AgentError, match="timed out"): + await runner.invoke("thread-1", "hello") diff --git a/tests/unit/test_load_agent_config_use_case.py b/tests/unit/test_load_agent_config_use_case.py index f9fe6ce..035565d 100644 --- a/tests/unit/test_load_agent_config_use_case.py +++ b/tests/unit/test_load_agent_config_use_case.py @@ -6,7 +6,7 @@ import pytest from src.application.use_cases.load_agent_config import LoadAgentConfigUseCase -from src.domain.exceptions import ConfigNotFoundError +from src.domain.errors.config import ConfigNotFoundError from src.infrastructure.yaml_config.adapter import YamlAgentConfigLoader diff --git a/tests/unit/test_mcp_adapter.py b/tests/unit/test_mcp_adapter.py index 898a73b..270aa6a 100644 --- a/tests/unit/test_mcp_adapter.py +++ b/tests/unit/test_mcp_adapter.py @@ -8,7 +8,7 @@ import pytest from src.domain.entities.mcp_server_config import McpServerConfig, McpTransportType -from src.domain.exceptions import McpConnectionError, McpToolLoadError +from src.domain.errors.mcp import McpConnectionError, McpToolLoadError from src.infrastructure.mcp.adapter import LangchainMcpToolLoader @@ -159,4 +159,50 @@ async def test_close_clears_clients(self): assert len(loader._clients) == 2 await loader.close() + + +class TestToolTimeout: + """Per-MCP-tool timeout: a hung tool must raise ToolException (recoverable) + so the agent can continue, instead of stalling or being killed.""" + + async def test_hung_tool_raises_tool_exception(self): + import asyncio + + from langchain_core.tools import StructuredTool, ToolException + + async def _hang(**_kwargs): + await asyncio.sleep(10) + return "never" + + tool = StructuredTool( + name="hung_tool", + description="a tool that hangs", + args_schema=None, + coroutine=_hang, + ) + + loader = LangchainMcpToolLoader(tool_timeout=0.1) + patched = loader._patch_sync_support([tool]) + assert len(patched) == 1 + + with pytest.raises(ToolException, match="timed out"): + await patched[0].coroutine() + + async def test_fast_tool_succeeds(self): + async def _fast(**_kwargs): + return "ok" + + from langchain_core.tools import StructuredTool + + tool = StructuredTool( + name="fast_tool", + description="a fast tool", + args_schema=None, + coroutine=_fast, + ) + + loader = LangchainMcpToolLoader(tool_timeout=5.0) + patched = loader._patch_sync_support([tool]) + result = await patched[0].coroutine() + assert result == "ok" assert len(loader._clients) == 0 diff --git a/tests/unit/test_minio_store.py b/tests/unit/test_minio_store.py index 1e27a95..0d946e5 100644 --- a/tests/unit/test_minio_store.py +++ b/tests/unit/test_minio_store.py @@ -9,7 +9,7 @@ import pytest -from src.domain.exceptions import AgentNotFoundError +from src.domain.errors.agent import AgentNotFoundError from src.infrastructure.minio_store.adapter import MinioAgentConfigStore BUCKET = "agent-configs" diff --git a/tests/unit/test_phoenix_prompt_manager.py b/tests/unit/test_phoenix_prompt_manager.py index be885f6..be46673 100644 --- a/tests/unit/test_phoenix_prompt_manager.py +++ b/tests/unit/test_phoenix_prompt_manager.py @@ -6,6 +6,7 @@ import pytest from phoenix.client.resources.prompts import PromptVersion as PhoenixPromptVersion +from src.domain.errors.prompt import PromptNotFoundError from src.infrastructure.prompt_management.phoenix_prompt_adapter import PhoenixPromptManagerProvider @@ -69,7 +70,7 @@ async def test_create_prompt_with_tags(self, manager): async def test_get_prompt_not_found(self, manager): manager._client.prompts.get = MagicMock(return_value=None) - with pytest.raises(ValueError, match="Prompt not found"): + with pytest.raises(PromptNotFoundError, match="Prompt not found"): await manager.get_prompt("nonexistent") @pytest.mark.asyncio diff --git a/tests/unit/test_postgres_repository.py b/tests/unit/test_postgres_repository.py index 13c0f3a..e326a2b 100644 --- a/tests/unit/test_postgres_repository.py +++ b/tests/unit/test_postgres_repository.py @@ -12,7 +12,7 @@ import pytest from src.domain.entities.agent_config_metadata import AgentConfigMetadata -from src.domain.exceptions import AgentNotFoundError +from src.domain.errors.agent import AgentNotFoundError class TestPostgresAgentConfigRepository: diff --git a/tests/unit/test_postgres_thread_repository.py b/tests/unit/test_postgres_thread_repository.py index fac878d..bbb33fb 100644 --- a/tests/unit/test_postgres_thread_repository.py +++ b/tests/unit/test_postgres_thread_repository.py @@ -13,7 +13,7 @@ from src.domain.entities.message import Message, MessageRole, MessageStatus from src.domain.entities.thread import Thread -from src.domain.exceptions import ThreadNotFoundError +from src.domain.errors.thread import ThreadNotFoundError class TestPostgresThreadRepository: diff --git a/tests/unit/test_prompt_use_cases.py b/tests/unit/test_prompt_use_cases.py index 32c5628..62c976f 100644 --- a/tests/unit/test_prompt_use_cases.py +++ b/tests/unit/test_prompt_use_cases.py @@ -8,6 +8,11 @@ from src.application.use_cases.get_prompt import GetPromptUseCase from src.application.use_cases.update_prompt import UpdatePromptUseCase from src.domain.entities.prompt import Prompt, PromptVersion +from src.domain.errors.prompt import ( + PromptAlreadyExistsError, + PromptManagerUnavailableError, + PromptNotFoundError, +) def _make_prompt(identifier: str = "test-prompt") -> Prompt: @@ -59,10 +64,10 @@ async def test_execute_success(self, mock_prompt_manager): ) async def test_execute_propagates_exception(self, mock_prompt_manager): - mock_prompt_manager.create_prompt.side_effect = ValueError("already exists") + mock_prompt_manager.create_prompt.side_effect = PromptAlreadyExistsError("already exists") use_case = CreatePromptUseCase(mock_prompt_manager) - with pytest.raises(ValueError, match="already exists"): + with pytest.raises(PromptAlreadyExistsError, match="already exists"): await use_case.execute( identifier="test-prompt", content=[{"role": "user", "content": "Hello"}], @@ -135,10 +140,10 @@ async def test_execute_with_tag(self, mock_prompt_manager): ) async def test_execute_not_found_raises(self, mock_prompt_manager): - mock_prompt_manager.get_prompt.side_effect = ValueError("Prompt not found: test-prompt") + mock_prompt_manager.get_prompt.side_effect = PromptNotFoundError("Prompt not found: test-prompt") use_case = GetPromptUseCase(mock_prompt_manager) - with pytest.raises(ValueError, match="not found"): + with pytest.raises(PromptNotFoundError, match="not found"): await use_case.execute(identifier="test-prompt") @@ -184,15 +189,15 @@ async def test_execute_partial_update(self, mock_prompt_manager): ) async def test_execute_not_found_raises(self, mock_prompt_manager): - mock_prompt_manager.update_prompt.side_effect = ValueError("Prompt not found: test-prompt") + mock_prompt_manager.update_prompt.side_effect = PromptNotFoundError("Prompt not found: test-prompt") use_case = UpdatePromptUseCase(mock_prompt_manager) - with pytest.raises(ValueError, match="not found"): + with pytest.raises(PromptNotFoundError, match="not found"): await use_case.execute(identifier="test-prompt") async def test_execute_propagates_exception(self, mock_prompt_manager): - mock_prompt_manager.update_prompt.side_effect = RuntimeError("Phoenix unavailable") + mock_prompt_manager.update_prompt.side_effect = PromptManagerUnavailableError("Phoenix unavailable") use_case = UpdatePromptUseCase(mock_prompt_manager) - with pytest.raises(RuntimeError, match="Phoenix unavailable"): - await use_case.execute(identifier="test-prompt") \ No newline at end of file + with pytest.raises(PromptManagerUnavailableError, match="Phoenix unavailable"): + await use_case.execute(identifier="test-prompt") diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index 958c0df..96e053a 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -14,7 +14,7 @@ from src.domain.entities.agent_config_metadata import AgentConfigMetadata from src.domain.entities.message import Message, MessageRole, MessageStatus from src.domain.entities.stream_event import StreamEvent, StreamEventType -from src.domain.exceptions import AgentError +from src.domain.errors.agent import AgentError from src.infrastructure.persistent_registry.adapter import PersistentAgentRegistry from src.infrastructure.yaml_config.adapter import YamlAgentConfigLoader from src.main import app @@ -97,7 +97,7 @@ def mock_config_store(yaml_store): async def _get(name): if name not in yaml_store: - from src.domain.exceptions import AgentNotFoundError + from src.domain.errors.agent import AgentNotFoundError raise AgentNotFoundError(f"Agent config not found: {name}") return yaml_store[name] diff --git a/tests/unit/test_send_message.py b/tests/unit/test_send_message.py index 92addd9..dbe6ef2 100644 --- a/tests/unit/test_send_message.py +++ b/tests/unit/test_send_message.py @@ -11,6 +11,7 @@ from src.application.requests.chat import ChatRequest from src.application.use_cases.send_message import SendMessageUseCase from src.domain.entities.message import Message, MessageRole, MessageStatus +from src.domain.errors.hitl import InvalidHitlActionError from src.domain.ports.agent_runner import AgentRunner @@ -80,8 +81,8 @@ async def test_edit_hitl_saves_response(self, registry, runner, thread_repo): updated_thread = await thread_repo.get(thread.id) assert len(updated_thread.messages) == 1 - async def test_unsupported_hitl_action_raises_value_error(self, registry, thread_repo): - """A HITL request with an unrecognized action should raise ValueError.""" + async def test_unsupported_hitl_action_raises_invalid_hitl_action_error(self, registry, thread_repo): + """A HITL request with an unrecognized action should raise InvalidHitlActionError.""" thread = await thread_repo.create("test-agent") use_case = SendMessageUseCase(registry, thread_repo) @@ -92,7 +93,7 @@ async def test_unsupported_hitl_action_raises_value_error(self, registry, thread request.reason = None request.edits = None - with pytest.raises(ValueError, match="Unsupported HITL action"): + with pytest.raises(InvalidHitlActionError, match="Unsupported HITL action"): await use_case.execute(thread.id, request) # --- _is_duplicate_human_message tests --- diff --git a/tests/unit/test_thread_management.py b/tests/unit/test_thread_management.py index 1c1cd96..8b9c3ab 100644 --- a/tests/unit/test_thread_management.py +++ b/tests/unit/test_thread_management.py @@ -16,7 +16,8 @@ ListThreadsUseCase, ) from src.domain.entities.agent_config_metadata import AgentConfigMetadata -from src.domain.exceptions import AgentNotFoundError, ThreadNotFoundError +from src.domain.errors.agent import AgentNotFoundError +from src.domain.errors.thread import ThreadNotFoundError from src.domain.ports.agent_config_repository import AgentConfigRepository from src.domain.ports.agent_config_store import AgentConfigStore from src.infrastructure.persistent_registry.adapter import PersistentAgentRegistry @@ -70,7 +71,7 @@ async def test_create_thread(self, thread_repo, registry): assert thread.id is not None async def test_create_thread_unknown_agent_raises(self, thread_repo, registry, mock_store): - from src.domain.exceptions import AgentNotFoundError as ANF + from src.domain.errors.agent import AgentNotFoundError as ANF mock_store.get.side_effect = ANF("not found") use_case = CreateThreadUseCase(thread_repo, registry) diff --git a/tests/unit/test_yaml_loader.py b/tests/unit/test_yaml_loader.py index 7a52941..a9c8fae 100644 --- a/tests/unit/test_yaml_loader.py +++ b/tests/unit/test_yaml_loader.py @@ -2,7 +2,7 @@ import pytest -from src.domain.exceptions import ConfigError, ConfigNotFoundError, ConfigValidationError +from src.domain.errors.config import ConfigError, ConfigNotFoundError, ConfigValidationError from src.infrastructure.yaml_config.adapter import YamlAgentConfigLoader diff --git a/uv.lock b/uv.lock index ea4d40c..267378b 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14'", @@ -18,7 +18,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.5" +version = "3.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -27,95 +27,111 @@ dependencies = [ { name = "frozenlist" }, { name = "multidict" }, { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" }, - { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" }, - { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" }, - { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" }, - { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" }, - { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" }, - { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" }, - { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" }, - { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" }, - { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" }, - { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" }, - { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" }, - { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, - { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, - { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, - { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, - { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, - { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, - { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, - { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, - { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, - { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, - { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, - { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, - { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, - { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, - { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, - { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, - { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" }, - { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" }, - { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" }, - { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" }, - { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" }, - { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" }, - { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" }, - { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" }, - { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" }, - { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, - { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, - { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, - { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, - { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, - { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, - { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, - { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, - { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, - { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, - { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, - { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, - { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, - { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, - { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, - { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, - { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, - { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, - { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, - { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, - { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, - { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, - { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, - { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, - { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, - { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, - { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, + { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, + { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, + { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, + { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, + { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, + { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, + { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, + { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, ] [[package]] @@ -177,7 +193,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.78.0" +version = "0.109.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -189,9 +205,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ec/51/32849a48f9b1cfe80a508fd269b20bd8f0b1357c70ba092890fde5a6a10b/anthropic-0.78.0.tar.gz", hash = "sha256:55fd978ab9b049c61857463f4c4e9e092b24f892519c6d8078cee1713d8af06e", size = 509136, upload-time = "2026-02-05T17:52:04.986Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/b7/9a8e2f79011e89dd6eeb599c27332aed765dac9d6fbee3a55e68e4e3ec25/anthropic-0.109.2.tar.gz", hash = "sha256:d37db299597c7bc124b49b767ff135f1e6456b64af2b2fad4b63b2a1df333cf0", size = 927559, upload-time = "2026-06-15T17:30:25.024Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/03/2f50931a942e5e13f80e24d83406714672c57964be593fc046d81369335b/anthropic-0.78.0-py3-none-any.whl", hash = "sha256:2a9887d2e99d1b0f9fe08857a1e9fe5d2d4030455dbf9ac65aab052e2efaeac4", size = 405485, upload-time = "2026-02-05T17:52:03.674Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f2/bee5de8a2699fc8a3cce34d61c7a2626a2c310ddde7ea5611327eb0ddbe9/anthropic-0.109.2-py3-none-any.whl", hash = "sha256:e0fb4ca5df0ed983248c9c6c3242adc81d9cfddb8725902da53698554117abac", size = 923800, upload-time = "2026-06-15T17:30:23.124Z" }, ] [[package]] @@ -540,6 +556,7 @@ name = "composable-agents" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "aiohttp" }, { name = "alembic" }, { name = "arize-phoenix-client" }, { name = "arize-phoenix-otel" }, @@ -548,21 +565,28 @@ dependencies = [ { name = "cryptography" }, { name = "deepagents" }, { name = "fastapi" }, + { name = "idna" }, { name = "langchain-core" }, { name = "langchain-mcp-adapters" }, { name = "langchain-openai" }, { name = "langgraph" }, + { name = "mako" }, + { name = "mcp" }, { name = "miniopy-async" }, { name = "openinference-instrumentation-langchain" }, { name = "pyasn1" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, { name = "pyyaml" }, { name = "requests" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "sse-starlette" }, + { name = "starlette" }, { name = "tenacity" }, + { name = "urllib3" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -578,29 +602,37 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiohttp", specifier = ">=3.14.1" }, { name = "alembic", specifier = ">=1.13.0" }, { name = "arize-phoenix-client", specifier = "==2.3.0" }, { name = "arize-phoenix-otel", specifier = "==0.15.0" }, { name = "asyncpg", specifier = ">=0.30.0" }, { name = "cachetools", specifier = ">=7.0.5" }, - { name = "cryptography", specifier = ">=46.0.5" }, - { name = "deepagents", specifier = ">=0.3.12" }, + { name = "cryptography", specifier = ">=48.0.1" }, + { name = "deepagents", specifier = ">=0.6.10" }, { name = "fastapi", specifier = ">=0.128.4" }, - { name = "langchain-core", specifier = ">=1.2.22" }, - { name = "langchain-mcp-adapters", specifier = ">=0.1.0" }, - { name = "langchain-openai", specifier = ">=1.1.7" }, - { name = "langgraph", specifier = ">=1.0.10" }, + { name = "idna", specifier = ">=3.15" }, + { name = "langchain-core", specifier = ">=1.4.7" }, + { name = "langchain-mcp-adapters", specifier = ">=0.3.0" }, + { name = "langchain-openai", specifier = ">=1.1.15" }, + { name = "langgraph", specifier = ">=1.2.5" }, + { name = "mako", specifier = ">=1.3.12" }, + { name = "mcp", specifier = ">=1.27.0" }, { name = "miniopy-async", specifier = ">=1.21.0" }, { name = "openinference-instrumentation-langchain", specifier = "==0.1.62" }, { name = "pyasn1", specifier = ">=0.6.3" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, - { name = "pyjwt", specifier = ">=2.12.0" }, + { name = "pyjwt", specifier = ">=2.13.0" }, + { name = "python-dotenv", specifier = ">=1.2.2" }, + { name = "python-multipart", specifier = ">=0.0.30" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "requests", specifier = ">=2.33.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.0" }, { name = "sse-starlette", specifier = ">=3.2.0" }, + { name = "starlette", specifier = ">=1.3.1" }, { name = "tenacity", specifier = ">=8.0.0" }, + { name = "urllib3", specifier = ">=2.7.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.40.0" }, ] @@ -720,77 +752,75 @@ toml = [ [[package]] name = "cryptography" -version = "46.0.6" +version = "49.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, - { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, - { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, - { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, - { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, - { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, - { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, - { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, - { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, - { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, - { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, - { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, - { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, - { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, - { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, - { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, - { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, - { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, - { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, - { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, - { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, - { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, - { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, - { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, - { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, - { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, - { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, - { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, - { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, - { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, - { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, - { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, - { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, - { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, - { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, - { url = "https://files.pythonhosted.org/packages/2e/84/7ccff00ced5bac74b775ce0beb7d1be4e8637536b522b5df9b73ada42da2/cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", size = 3475444, upload-time = "2026-03-25T23:34:38.944Z" }, - { url = "https://files.pythonhosted.org/packages/bc/1f/4c926f50df7749f000f20eede0c896769509895e2648db5da0ed55db711d/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", size = 4218227, upload-time = "2026-03-25T23:34:40.871Z" }, - { url = "https://files.pythonhosted.org/packages/c6/65/707be3ffbd5f786028665c3223e86e11c4cda86023adbc56bd72b1b6bab5/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", size = 4381399, upload-time = "2026-03-25T23:34:42.609Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6d/73557ed0ef7d73d04d9aba745d2c8e95218213687ee5e76b7d236a5030fc/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", size = 4217595, upload-time = "2026-03-25T23:34:44.205Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c5/e1594c4eec66a567c3ac4400008108a415808be2ce13dcb9a9045c92f1a0/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", size = 4380912, upload-time = "2026-03-25T23:34:46.328Z" }, - { url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955, upload-time = "2026-03-25T23:34:48.465Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, ] [[package]] name = "deepagents" -version = "0.3.12" +version = "0.6.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain" }, { name = "langchain-anthropic" }, { name = "langchain-core" }, { name = "langchain-google-genai" }, + { name = "langsmith" }, { name = "wcmatch" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/0b/9d3512327d48e619567797dffb34c356b2e0c7b0aa505fd3aaef342903d3/deepagents-0.3.12.tar.gz", hash = "sha256:ab2d7e7c47040d364a20cc19cc775294c1e942456652d6c12e0f21011068633c", size = 77962, upload-time = "2026-02-06T21:20:43.511Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/ab/3225f47404d401559ab67819b3b20833cdefe7e283e39f796d6019c7dfa7/deepagents-0.6.10.tar.gz", hash = "sha256:bce9f8e6b7870fe1bba5e5a128588e6f38df810f60695e01dd568e6e62e74a89", size = 203631, upload-time = "2026-06-13T06:19:48.694Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/0a/2b8542a19bb22cf49827a38d04e045c6dba97d03dddc721d3ed2e7be0e5e/deepagents-0.3.12-py3-none-any.whl", hash = "sha256:42e707a1be48db3bc445fbe3243b6dc19333565cac4eab8cdc0e37d780c6cfe7", size = 88553, upload-time = "2026-02-06T21:20:42.575Z" }, + { url = "https://files.pythonhosted.org/packages/1f/81/49f1a98434b462aa60a07ef5a98437bd6a4445219b91c459e1e7e5d5564e/deepagents-0.6.10-py3-none-any.whl", hash = "sha256:21486ba213f027f7f2d5b4822bf6099f806a1d325dd33e93d3f5b9e857b2ea89", size = 228251, upload-time = "2026-06-13T06:19:47.499Z" }, ] [[package]] @@ -813,7 +843,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.128.4" +version = "0.137.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -822,9 +852,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/b7/21bf3d694cbff0b7cf5f459981d996c2c15e072bd5ca5609806383947f1e/fastapi-0.128.4.tar.gz", hash = "sha256:d6a2cc4c0edfbb2499f3fdec55ba62e751ee58a6354c50f85ed0dabdfbcfeb60", size = 375898, upload-time = "2026-02-07T08:14:09.616Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/b1/e5b92c59d2c37817e77c1a8c2fc1f79cdcc04c68253e5406b43e3204cba7/fastapi-0.137.1.tar.gz", hash = "sha256:822360704230d9533d8d9475399613525968aa2f0b5bd2a3ccc9f18c88fd541c", size = 408293, upload-time = "2026-06-15T11:28:20.79Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/8b/c8050e556f5d7a1f33a93c2c94379a0bae23c58a79ad9709d7e052d0c3b8/fastapi-0.128.4-py3-none-any.whl", hash = "sha256:9321282cee605fd2075ccbc95c0f2e549d675c59de4a952bba202cd1730ac66b", size = 103684, upload-time = "2026-02-07T08:14:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/380b9a5922f4340e51c309cde09e5bd32e62f02302971bee30dc15aa0624/fastapi-0.137.1-py3-none-any.whl", hash = "sha256:64f6983c59e45c4b9fdc44e57cb8035c2451ee91ea8e8ec042aca37de7cf6b69", size = 121877, upload-time = "2026-06-15T11:28:19.523Z" }, ] [[package]] @@ -943,16 +973,15 @@ wheels = [ [[package]] name = "google-auth" -version = "2.48.0" +version = "2.55.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, - { name = "rsa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/1c/70b23fc52b2bb3c70b379f3bd05c4a60ab3a873e30c6bd21c57e0154848a/google_auth-2.55.0.tar.gz", hash = "sha256:fcd3a130f575fa36403d38774af1c64a4fbfbca09215f0589d2372b5119697cb", size = 349379, upload-time = "2026-06-15T22:33:16.466Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, + { url = "https://files.pythonhosted.org/packages/44/71/c0321dc6d63d99946da45f7c06299b934e4f7f7da5c4f14d101bcb39adf1/google_auth-2.55.0-py3-none-any.whl", hash = "sha256:a17cef9dedf98c4ebae2fb0c48c8f75952c877cbc2efe09f329ef16c2783d88a", size = 252400, upload-time = "2026-06-15T22:33:14.992Z" }, ] [package.optional-dependencies] @@ -962,7 +991,7 @@ requests = [ [[package]] name = "google-genai" -version = "1.62.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -976,9 +1005,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/4c/71b32b5c8db420cf2fd0d5ef8a672adbde97d85e5d44a0b4fca712264ef1/google_genai-1.62.0.tar.gz", hash = "sha256:709468a14c739a080bc240a4f3191df597bf64485b1ca3728e0fb67517774c18", size = 490888, upload-time = "2026-02-04T22:48:41.989Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/52/0244e310812f3063d09d60b30ae29ab7df9343bd005744cd5eeaa6ba39b4/google_genai-2.8.0.tar.gz", hash = "sha256:37a9b3cb127d763e7f4ca47452ae3562c87728773bd1b149f7b559c239da2bc1", size = 564955, upload-time = "2026-06-03T22:55:38.397Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/5f/4645d8a28c6e431d0dd6011003a852563f3da7037d36af53154925b099fd/google_genai-1.62.0-py3-none-any.whl", hash = "sha256:4c3daeff3d05fafee4b9a1a31f9c07f01bc22051081aa58b4d61f58d16d1bcc0", size = 724166, upload-time = "2026-02-04T22:48:39.956Z" }, + { url = "https://files.pythonhosted.org/packages/e2/de/747ad1aa49e902da9a4699081c282a1ed8ceed3b4d295fd99a6d286e09e4/google_genai-2.8.0-py3-none-any.whl", hash = "sha256:4da0a223a100f4b37f609a68b835e3326ab0fa313314dc0fd9d34e76ee293844", size = 832497, upload-time = "2026-06-03T22:55:36.598Z" }, ] [[package]] @@ -1180,11 +1209,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -1343,38 +1372,39 @@ wheels = [ [[package]] name = "langchain" -version = "1.2.13" +version = "1.3.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "langgraph" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/e5/56fdeedaa0ef1be3c53721d382d9e21c63930179567361610ea6102c04ea/langchain-1.2.13.tar.gz", hash = "sha256:d566ef67c8287e7f2e2df3c99bf3953a6beefd2a75a97fe56ecce905e21f3ef4", size = 573819, upload-time = "2026-03-19T17:16:07.641Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/7c/651d0dc4913a7a892156c03dd343b99cfe19ee729e6911ab1f4fe7567b8b/langchain-1.3.9.tar.gz", hash = "sha256:9b14ef0db9ef314299ded858b22ca2a40b8f1b05c8c9cb6b82d53a53075fef00", size = 631514, upload-time = "2026-06-12T16:53:27.083Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/1d/a509af07535d8f4621d77f3ba5ec846ee6d52c59d2239e1385ec3b29bf92/langchain-1.2.13-py3-none-any.whl", hash = "sha256:37d4526ac4b0cdd3d7706a6366124c30dc0771bf5340865b37cdc99d5e5ad9b1", size = 112488, upload-time = "2026-03-19T17:16:06.134Z" }, + { url = "https://files.pythonhosted.org/packages/b7/55/3481619d21b9bdfbfda8680fba5cfc6cfe926789b8eaaad95353078cfa20/langchain-1.3.9-py3-none-any.whl", hash = "sha256:4af49ad1095799e4408b489fb79d4b8b49292453618b202d8a697fca59bb6871", size = 132873, upload-time = "2026-06-12T16:53:25.489Z" }, ] [[package]] name = "langchain-anthropic" -version = "1.3.2" +version = "1.4.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anthropic" }, { name = "langchain-core" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/dd/c5e094079bdd748ca3f0bd0a09189ed2fa46bba56b5a8351198dc7c19e1f/langchain_anthropic-1.3.2.tar.gz", hash = "sha256:e551726a6ebf20229bde06022b5149d33bd48d28e34bd002a744953667b8ad48", size = 686239, upload-time = "2026-02-06T16:14:46.199Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/f5/cd397b94aeed5fa0e8ab9595b9fb578ac99f424d42220defe6626e6a1a7b/langchain_anthropic-1.4.6.tar.gz", hash = "sha256:78942d4458d883b7d362438a095ed501ed84f44d402622404482481fc973b9da", size = 706540, upload-time = "2026-06-12T16:54:15.352Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/6b/2da16c32308f79bb4588cec7095edbc770722ae4b3c3a1c135e05b0bdc2e/langchain_anthropic-1.3.2-py3-none-any.whl", hash = "sha256:35bc30862696a493680b898eb76bd6c866841f8e48a57d5eca1420a4fd807ac0", size = 46751, upload-time = "2026-02-06T16:14:44.734Z" }, + { url = "https://files.pythonhosted.org/packages/26/af/927dbbc5a1f5fea1a69adc2883f034cbd1430004e36f4eacd302d500393a/langchain_anthropic-1.4.6-py3-none-any.whl", hash = "sha256:dbd412a956b6b8b0716d9d8460ef71f834a6731cdbfc59e6160482a4a9fb5200", size = 51797, upload-time = "2026-06-12T16:54:14.159Z" }, ] [[package]] name = "langchain-core" -version = "1.2.23" +version = "1.4.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, + { name = "langchain-protocol" }, { name = "langsmith" }, { name = "packaging" }, { name = "pydantic" }, @@ -1383,14 +1413,14 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1d/47/a5f21b651e9cbd7a26c3e5809336d10a0be94ef7bdf6bea47f2ad9fff1a8/langchain_core-1.2.23.tar.gz", hash = "sha256:fdec64f90cfea25317e88d9803c44684af1f4e30dec4e58320dd7393bb0f0785", size = 841684, upload-time = "2026-03-27T23:28:14.6Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/2b/fffaff399d20a56d40b9562fa19701e91abd72d8c9d9bc8c2673077b56b6/langchain_core-1.4.7.tar.gz", hash = "sha256:7a825d77de0a3f39adbd9d09612a75e85527e14a52c1601089bcc062972d9f2b", size = 952522, upload-time = "2026-06-12T19:23:57.588Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/5a/6ff2d76618e4cac531ea51d4ef44c6add36575a84c3f0f8877aee68c951a/langchain_core-1.2.23-py3-none-any.whl", hash = "sha256:70866dfc5275b7840ce272ff70f0ff216c8666ab25dc1b41964a4ef58c02a3ff", size = 506709, upload-time = "2026-03-27T23:28:13.372Z" }, + { url = "https://files.pythonhosted.org/packages/de/3e/dcdffa60078ae7b3a00ebb4cbbf1a204a14c3609983c604886523a7d4418/langchain_core-1.4.7-py3-none-any.whl", hash = "sha256:bcadd51951140ecdcba98311dbd931ba5de02a5ba8a2288dad5069c1eea2a13d", size = 554941, upload-time = "2026-06-12T19:23:55.826Z" }, ] [[package]] name = "langchain-google-genai" -version = "4.2.0" +version = "4.2.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filetype" }, @@ -1398,42 +1428,54 @@ dependencies = [ { name = "langchain-core" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/0b/eae2305e207574dc633983a8a82a745e0ede1bce1f3a9daff24d2341fadc/langchain_google_genai-4.2.0.tar.gz", hash = "sha256:9a8d9bfc35354983ed29079cefff53c3e7c9c2a44b6ba75cc8f13a0cf8b55c33", size = 277361, upload-time = "2026-01-13T20:41:17.63Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/4b/a1acdba3a86f861d379cb654f234d334c04a4c93178c8c7b0182ddeb9966/langchain_google_genai-4.2.5.tar.gz", hash = "sha256:2abab4be22699a9cc29948b2bf012946f51a0bbf10ab3a4a9a129047234829f8", size = 271850, upload-time = "2026-06-10T01:48:57.06Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/51/39942c0083139652494bb354dddf0ed397703a4882302f7b48aeca531c96/langchain_google_genai-4.2.0-py3-none-any.whl", hash = "sha256:856041aaafceff65a4ef0d5acf5731f2db95229ff041132af011aec51e8279d9", size = 66452, upload-time = "2026-01-13T20:41:16.296Z" }, + { url = "https://files.pythonhosted.org/packages/6a/82/3d4d3dc181ea1756f323dad4d5936239c2f404ea0acb5102316224280634/langchain_google_genai-4.2.5-py3-none-any.whl", hash = "sha256:289699ddb8e1076a76144f83e25e0086e4ce629b196fc103251f2a629e0756e5", size = 69404, upload-time = "2026-06-10T01:48:56.09Z" }, ] [[package]] name = "langchain-mcp-adapters" -version = "0.2.1" +version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "mcp" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/52/cebf0ef5b1acef6cbc63d671171d43af70f12d19f55577909c7afa79fb6e/langchain_mcp_adapters-0.2.1.tar.gz", hash = "sha256:58e64c44e8df29ca7eb3b656cf8c9931ef64386534d7ca261982e3bdc63f3176", size = 36394, upload-time = "2025-12-09T16:28:38.98Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/1c/b179d8650d2349a342bc1fd1aab41b34154e79c7fc86fc42bdf0bb110d6f/langchain_mcp_adapters-0.3.0.tar.gz", hash = "sha256:fa6c9497015eb2807de5d0c341a36e1d2445cecbae1f4a24e922fc5b94f1a36c", size = 42774, upload-time = "2026-06-10T01:10:28.552Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/81/b2479eb26861ab36be851026d004b2d391d789b7856e44c272b12828ece0/langchain_mcp_adapters-0.2.1-py3-none-any.whl", hash = "sha256:9f96ad4c64230f6757297fec06fde19d772c99dbdfbca987f7b7cfd51ff77240", size = 22708, upload-time = "2025-12-09T16:28:37.877Z" }, + { url = "https://files.pythonhosted.org/packages/66/89/b4869db84ce529de6c7548319197df50c24d0f8a7412f74f889e22324036/langchain_mcp_adapters-0.3.0-py3-none-any.whl", hash = "sha256:1af511a95e028d9546502e360f95698ae8b691dbc07981fc48170c2cb1ebd7a9", size = 25855, upload-time = "2026-06-10T01:10:27.524Z" }, ] [[package]] name = "langchain-openai" -version = "1.1.7" +version = "1.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "openai" }, { name = "tiktoken" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/b7/30bfc4d1b658a9ee524bcce3b0b2ec9c45a11c853a13c4f0c9da9882784b/langchain_openai-1.1.7.tar.gz", hash = "sha256:f5ec31961ed24777548b63a5fe313548bc6e0eb9730d6552b8c6418765254c81", size = 1039134, upload-time = "2026-01-07T19:44:59.728Z" } +sdist = { url = "https://files.pythonhosted.org/packages/93/4c/cf3c5a03f1d2e2e4367c1527231162a99d0f1c94113e1203c00469c860e4/langchain_openai-1.3.2.tar.gz", hash = "sha256:240917ae88d754b389a6f2ae06fa262c50c094eb4f576c27d560dff6b86c2f62", size = 3236213, upload-time = "2026-06-13T05:42:12.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/21/cbf6c3786de881b214c8c6c9f61fe44c9c47608428676a5cd5c5b2b0cda5/langchain_openai-1.3.2-py3-none-any.whl", hash = "sha256:3d247f43bba9f85d32a374b1bdf3932a0d1e3c60913ebeadf68630de52add67e", size = 119775, upload-time = "2026-06-13T05:42:11.088Z" }, +] + +[[package]] +name = "langchain-protocol" +version = "0.0.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/b3/4e2429876c7a35585618caa2b9f9089f7162a6b50562b614ad82ac11c17e/langchain_protocol-0.0.17.tar.gz", hash = "sha256:e7cbe58c205df4b4fd87dc6d5bb23f10e13b236d0e2e1b0b9d05bc2b648f3eea", size = 6026, upload-time = "2026-06-12T18:39:51.923Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/a1/50e7596aca775d8c3883eceeaf47489fac26c57c1abe243c00174f715a8a/langchain_openai-1.1.7-py3-none-any.whl", hash = "sha256:34e9cd686aac1a120d6472804422792bf8080a2103b5d21ee450c9e42d053815", size = 84753, upload-time = "2026-01-07T19:44:58.629Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/a1bfe72c6ec856e99773bbd96c8086421e554b3693d0142b9ea009c6ac92/langchain_protocol-0.0.17-py3-none-any.whl", hash = "sha256:982a08fe152586ed10d4ff3d538c2e0b5766e5f307cdea325e10be3f2c17cae6", size = 7096, upload-time = "2026-06-12T18:39:50.973Z" }, ] [[package]] name = "langgraph" -version = "1.1.3" +version = "1.2.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, @@ -1443,53 +1485,56 @@ dependencies = [ { name = "pydantic" }, { name = "xxhash" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d2/b2/e7db624e8b0ee063ecfbf7acc09467c0836a05914a78e819dfb3744a0fac/langgraph-1.1.3.tar.gz", hash = "sha256:ee496c297a9c93b38d8560be15cbb918110f49077d83abd14976cb13ac3b3370", size = 545120, upload-time = "2026-03-18T23:42:58.24Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9d/7c9ebd17b95569122e2d2e641f535cf086c870d66bb8e59be33cdba856b3/langgraph-1.2.5.tar.gz", hash = "sha256:09a3bdec6fdb3228623fc78b6f69a1400d383f66348d0b04d0efb692022cc6ef", size = 712532, upload-time = "2026-06-12T20:30:58.498Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/f7/221cc479e95e03e260496616e5ce6fb50c1ea01472e3a5bc481a9b8a2f83/langgraph-1.1.3-py3-none-any.whl", hash = "sha256:57cd6964ebab41cbd211f222293a2352404e55f8b2312cecde05e8753739b546", size = 168149, upload-time = "2026-03-18T23:42:56.967Z" }, + { url = "https://files.pythonhosted.org/packages/a2/03/187281cf61845c5a9c397ae6cd9cd73bb54b39435e5575a7b83c853e5b76/langgraph-1.2.5-py3-none-any.whl", hash = "sha256:9286bb5def82fc865959c14378fe473518dc097d586225f622f029637a2a4bb9", size = 246150, upload-time = "2026-06-12T20:30:57.018Z" }, ] [[package]] name = "langgraph-checkpoint" -version = "4.0.0" +version = "4.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "ormsgpack" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/76/55a18c59dedf39688d72c4b06af73a5e3ea0d1a01bc867b88fbf0659f203/langgraph_checkpoint-4.0.0.tar.gz", hash = "sha256:814d1bd050fac029476558d8e68d87bce9009a0262d04a2c14b918255954a624", size = 137320, upload-time = "2026-01-12T20:30:26.38Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/47/886af6f886f0bff2273164a45f008694e48a96ff3cd25ff0228f2aa9480e/langgraph_checkpoint-4.1.1.tar.gz", hash = "sha256:6c2bdb530c91f91d7d9c1bd100925d0fc4f498d418c17f3587d1526279482a25", size = 184020, upload-time = "2026-05-22T16:57:38.503Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/de/ddd53b7032e623f3c7bcdab2b44e8bf635e468f62e10e5ff1946f62c9356/langgraph_checkpoint-4.0.0-py3-none-any.whl", hash = "sha256:3fa9b2635a7c5ac28b338f631abf6a030c3b508b7b9ce17c22611513b589c784", size = 46329, upload-time = "2026-01-12T20:30:25.2Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl", hash = "sha256:25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e", size = 56212, upload-time = "2026-05-22T16:57:37.203Z" }, ] [[package]] name = "langgraph-prebuilt" -version = "1.0.8" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "langgraph-checkpoint" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/06/dd61a5c2dce009d1b03b1d56f2a85b3127659fdddf5b3be5d8f1d60820fb/langgraph_prebuilt-1.0.8.tar.gz", hash = "sha256:0cd3cf5473ced8a6cd687cc5294e08d3de57529d8dd14fdc6ae4899549efcf69", size = 164442, upload-time = "2026-02-19T18:14:39.083Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/66/ed9b93f56bc17ef22d551892f0ac2b225a97fe0fcf23a511b857f70d590b/langgraph_prebuilt-1.1.0.tar.gz", hash = "sha256:3c579cf6eed2d17f9c157c2d0fcaddcd8688524e7022d3b22b37a3bf4589d528", size = 178833, upload-time = "2026-05-12T03:37:49.332Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/41/ec966424ad3f2ed3996d24079d3342c8cd6c0bd0653c12b2a917a685ec6c/langgraph_prebuilt-1.0.8-py3-none-any.whl", hash = "sha256:d16a731e591ba4470f3e313a319c7eee7dbc40895bcf15c821f985a3522a7ce0", size = 35648, upload-time = "2026-02-19T18:14:37.611Z" }, + { url = "https://files.pythonhosted.org/packages/e9/43/3fe1a700b8490ed02679cdbbc8c915eb23a092faf496c9c1118abcd10be3/langgraph_prebuilt-1.1.0-py3-none-any.whl", hash = "sha256:51e311747d755b751d5c6b39b0c1446124d3a7643d2515017e6714b323508fc9", size = 41043, upload-time = "2026-05-12T03:37:48.007Z" }, ] [[package]] name = "langgraph-sdk" -version = "0.3.4" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, + { name = "langchain-core" }, + { name = "langchain-protocol" }, { name = "orjson" }, + { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/11/37/1c18ebb9090a29cd360abce7ee0d3c639fa680e20a078b8c5e85044443d9/langgraph_sdk-0.3.4.tar.gz", hash = "sha256:a8055464027c70ff7b454c0d67caec9a91c6a2bc75c66d023d3ce48773a2a774", size = 132239, upload-time = "2026-02-06T00:44:14.309Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327, upload-time = "2026-06-01T17:51:19.856Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/e6/df257026e1370320b60d54492c0847631729ad80ca8d8571b55ece594281/langgraph_sdk-0.3.4-py3-none-any.whl", hash = "sha256:eb73a2fb57a4167aeb31efeaf0c4daecd2cf0c942e8a376670fd1cc636992f49", size = 67833, upload-time = "2026-02-06T00:44:12.795Z" }, + { url = "https://files.pythonhosted.org/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd", size = 160521, upload-time = "2026-06-01T17:51:18.849Z" }, ] [[package]] name = "langsmith" -version = "0.6.9" +version = "0.8.16" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -1499,12 +1544,13 @@ dependencies = [ { name = "requests" }, { name = "requests-toolbelt" }, { name = "uuid-utils" }, + { name = "websockets" }, { name = "xxhash" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9a/e0/463a70b43d6755b01598bb59932eec8e2029afcab455b5312c318ac457b5/langsmith-0.6.9.tar.gz", hash = "sha256:aae04cec6e6d8e133f63ba71c332ce0fbd2cda95260db7746ff4c3b6a3c41db1", size = 973557, upload-time = "2026-02-05T20:10:55.629Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/19/1ed2af9c6d5d7a148e6b3e809b0af8ce8848e1f66a0726c8223d30e5292b/langsmith-0.8.16.tar.gz", hash = "sha256:8c943f0c9185fe2a9637b5b442828b7efd823b1de28d50d14c136c79660f909b", size = 4513275, upload-time = "2026-06-15T17:41:24.413Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/8e/063e09c5e8a3dcd77e2a8f0bff3f71c1c52a9d238da1bcafd2df3281da17/langsmith-0.6.9-py3-none-any.whl", hash = "sha256:86ba521e042397f6fbb79d63991df9d5f7b6a6dd6a6323d4f92131291478dcff", size = 319228, upload-time = "2026-02-05T20:10:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/c3/13/8186a9867c67f3fef9958a1d60b45f46c1a9b5d28f67d8fd136f28ceab3f/langsmith-0.8.16-py3-none-any.whl", hash = "sha256:081e57c0175d142192683288740a796eb0eb32d9e703b4bf9133678ceefe3286", size = 500303, upload-time = "2026-06-15T17:41:22.33Z" }, ] [[package]] @@ -1572,14 +1618,14 @@ wheels = [ [[package]] name = "mako" -version = "1.3.10" +version = "1.3.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, ] [[package]] @@ -1658,7 +1704,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.26.0" +version = "1.28.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1676,9 +1722,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c1/ee/94c6c50ffc5b5cf4737052275d11b57367f32d1a8516e31dcd60591b3916/mcp-1.28.0.tar.gz", hash = "sha256:559d3f9943674cafbe5744c5d3794f3237e8b47f9bbc58e20c0fad680d8487c2", size = 636040, upload-time = "2026-06-16T21:37:17.996Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/4c1dc1fbb688641a712d34650c3d58bbbdcb314ddb75bc5817bbf33515a4/mcp-1.28.0-py3-none-any.whl", hash = "sha256:9c1e7cf3a9125557e418ecd4fed8e9adddce81b0dfdae4d6601d700f5beb71a4", size = 221959, upload-time = "2026-06-16T21:37:16.579Z" }, ] [[package]] @@ -1864,7 +1910,7 @@ wheels = [ [[package]] name = "openai" -version = "2.17.0" +version = "2.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1876,9 +1922,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/a2/677f22c4b487effb8a09439fb6134034b5f0a39ca27df8b95fac23a93720/openai-2.17.0.tar.gz", hash = "sha256:47224b74bd20f30c6b0a6a329505243cb2f26d5cf84d9f8d0825ff8b35e9c999", size = 631445, upload-time = "2026-02-05T16:27:40.953Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/fa/88d0c58a0c58df7e6758e66b99c5d028d5e0bb49f8812d7203940cd9dbf1/openai-2.43.0.tar.gz", hash = "sha256:e74d238200a26868977002190fb6631613480a93dfe0c9c982e77021ed60a017", size = 785369, upload-time = "2026-06-17T17:06:56.06Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/97/284535aa75e6e84ab388248b5a323fc296b1f70530130dee37f7f4fbe856/openai-2.17.0-py3-none-any.whl", hash = "sha256:4f393fd886ca35e113aac7ff239bcd578b81d8f104f5aedc7d3693eb2af1d338", size = 1069524, upload-time = "2026-02-05T16:27:38.941Z" }, + { url = "https://files.pythonhosted.org/packages/a3/d2/ba767f4bbb30776c03d40906a2d3afad716a165ffa1771fc23b8992f7920/openai-2.43.0-py3-none-any.whl", hash = "sha256:65a670b54fadf2268c9e1330133373c963eb779ee969e5cbad419ec2c21dce97", size = 1355077, upload-time = "2026-06-17T17:06:53.614Z" }, ] [[package]] @@ -2504,11 +2550,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.12.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [package.optional-dependencies] @@ -2561,20 +2607,20 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] name = "python-multipart" -version = "0.0.22" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] [[package]] @@ -2904,18 +2950,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, ] -[[package]] -name = "rsa" -version = "4.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, -] - [[package]] name = "ruff" version = "0.15.0" @@ -3023,15 +3057,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.52.1" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] @@ -3186,11 +3220,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]]