diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 62280d1..dfb70f3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -79,6 +79,12 @@ repos: - pydantic-settings>=2.0 - httpx>=0.27 - typing-extensions>=4.0 + # Optional-extra deps required so the now-strictly-checked modules + # under ``locus.a2a``, ``locus.integrations``, ``locus.server`` etc. + # resolve their third-party imports. + - fastapi>=0.110 + - fastmcp>=3.2.0 + - mcp>=1.0 pass_filenames: false entry: mypy src/locus diff --git a/pyproject.toml b/pyproject.toml index bb5e24a..87e30ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -525,16 +525,11 @@ ignore_missing_imports = true module = [ "locus.rag.*", "locus.memory.*", - "locus.integrations.*", - "locus.streaming.*", "locus.loop.*", - "locus.playbooks.*", "locus.server.*", "locus.multiagent.graph", "locus.hooks.builtin.*", "locus.reasoning.*", - "locus.skills.*", - "locus.a2a.*", ] ignore_errors = true diff --git a/src/locus/a2a/protocol.py b/src/locus/a2a/protocol.py index 2a7fb4c..aac8495 100644 --- a/src/locus/a2a/protocol.py +++ b/src/locus/a2a/protocol.py @@ -30,6 +30,7 @@ import logging import os import uuid +from collections.abc import AsyncIterator from typing import Any from pydantic import BaseModel, Field @@ -238,7 +239,7 @@ async def stream( user_msgs = [m for m in request.messages if m.role == "user"] prompt = user_msgs[-1].content if user_msgs else "" - async def event_generator(): + async def event_generator() -> AsyncIterator[str]: try: async for event in agent.run(prompt): if isinstance(event, ThinkEvent): diff --git a/src/locus/integrations/fastmcp.py b/src/locus/integrations/fastmcp.py index e4b3d09..f0442b1 100644 --- a/src/locus/integrations/fastmcp.py +++ b/src/locus/integrations/fastmcp.py @@ -18,7 +18,7 @@ import json import re from collections.abc import Callable -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from pydantic import BaseModel, ConfigDict, Field, create_model @@ -68,7 +68,10 @@ def _json_schema_type_to_python(prop: dict[str, Any]) -> type[Any]: "boolean": bool, } - return mapping.get(schema_type, Any) + # ``Any`` is a typing-special-form, not a runtime type, so mypy can't + # accept it as the dict default. Pydantic accepts it as a field type + # at runtime, so we tag the line. + return mapping.get(schema_type, Any) # type: ignore[arg-type] def build_args_model(tool_name: str, schema: dict[str, Any] | None) -> type[BaseModel] | None: @@ -315,7 +318,7 @@ def _create_mcp(self) -> FastMCP: async def run_agent(prompt: str) -> str: """Run the Locus agent with a prompt and return the response.""" result = agent.run_sync(prompt) - return result.message + return str(result.message) # Register a streaming version @mcp.tool() @@ -327,17 +330,17 @@ async def run_agent_stream(prompt: str) -> str: # Return the final message from the last event for event in reversed(events): if hasattr(event, "final_message") and event.final_message: - return event.final_message + return str(event.final_message) return "Agent completed without response" return mcp - def run(self, transport: str = "stdio") -> None: + def run(self, transport: Literal["stdio", "http", "sse", "streamable-http"] = "stdio") -> None: """ Run the MCP server. Args: - transport: Transport type ("stdio" or "sse") + transport: Transport type ("stdio", "http", "sse", or "streamable-http"). """ if self._mcp is None: self._mcp = self._create_mcp() @@ -491,6 +494,9 @@ async def connect(self) -> None: async def _connect_http(self) -> None: """Connect via HTTP/SSE transport.""" + if self.base_url is None: + msg = "_connect_http called without base_url" + raise RuntimeError(msg) try: from mcp.client.session import ClientSession from mcp.client.streamable_http import streamablehttp_client @@ -517,7 +523,9 @@ class BearerAuth(httpx.Auth): def __init__(self, token: str): self.token = token - def auth_flow(self, request): + def auth_flow( # type: ignore[no-untyped-def] + self, request + ): # httpx.Auth.auth_flow signature varies across SDK versions request.headers["Authorization"] = f"Bearer {self.token}" yield request @@ -647,12 +655,17 @@ async def close(self) -> None: pass self._client_context = None - async def __aenter__(self): + async def __aenter__(self) -> MCPClient: """Async context manager entry.""" await self.connect() return self - async def __aexit__(self, exc_type, exc_val, exc_tb): + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: Any, + ) -> None: """Async context manager exit.""" await self.close() diff --git a/src/locus/playbooks/hook.py b/src/locus/playbooks/hook.py index 7b19182..a9b2d8a 100644 --- a/src/locus/playbooks/hook.py +++ b/src/locus/playbooks/hook.py @@ -100,7 +100,12 @@ def enforcer(self) -> PlaybookEnforcer: async def on_before_tool_call(self, event: BeforeToolCallEvent) -> None: """Validate the call against the current step; cancel on violation.""" - result = self._enforcer.validate_tool_call(event.tool_name) + # ``ProtectedEvent`` (the base class for hook events) sets fields + # via ``self._init(name, value)`` rather than class-level annotations, + # so mypy can't see ``.tool_name`` / ``.error`` statically. They + # exist at runtime; the ignore is the standard pattern for this + # protocol. + result = self._enforcer.validate_tool_call(event.tool_name) # type: ignore[attr-defined] if result.allowed: return # Build a useful cancel message that the agent loop will turn into @@ -120,13 +125,16 @@ async def on_after_tool_call(self, event: AfterToolCallEvent) -> None: before-hook cancelled the call, so anything reaching this method actually executed. """ - if event.error: + # ``ProtectedEvent`` sets ``.error`` / ``.tool_name`` via + # ``self._init(...)`` not class-level fields — see note in + # ``on_before_tool_call``. + if event.error: # type: ignore[attr-defined] # Failed calls don't advance the step (the model will likely # retry); they're still recorded for the violation log. - self._enforcer.record_tool_call(event.tool_name) + self._enforcer.record_tool_call(event.tool_name) # type: ignore[attr-defined] return - self._enforcer.record_tool_call(event.tool_name) + self._enforcer.record_tool_call(event.tool_name) # type: ignore[attr-defined] step = self._enforcer.current_step if step is None: diff --git a/src/locus/playbooks/loader.py b/src/locus/playbooks/loader.py index 0158547..ca24d9b 100644 --- a/src/locus/playbooks/loader.py +++ b/src/locus/playbooks/loader.py @@ -126,7 +126,7 @@ def load_yaml_string(self, yaml_string: str) -> Playbook: PlaybookLoadError: If YAML is invalid or playbook validation fails """ try: - import yaml + import yaml # type: ignore[import-untyped] # PyYAML ships no inline types except ImportError as e: raise PlaybookLoadError( "PyYAML is required for YAML support. Install with: pip install pyyaml" diff --git a/src/locus/skills/models.py b/src/locus/skills/models.py index 8369b0f..0ec536d 100644 --- a/src/locus/skills/models.py +++ b/src/locus/skills/models.py @@ -29,7 +29,7 @@ from pathlib import Path from typing import Any -import yaml +import yaml # type: ignore[import-untyped] # PyYAML ships no inline types # AgentSkills.io name validation: kebab-case, 1-64 chars, no consecutive hyphens diff --git a/src/locus/streaming/sse.py b/src/locus/streaming/sse.py index 4b35003..2113122 100644 --- a/src/locus/streaming/sse.py +++ b/src/locus/streaming/sse.py @@ -184,7 +184,8 @@ def _serialize_event(self, event: LocusEvent) -> dict[str, Any]: Dictionary representation of the event """ if self.custom_serializer: - return self.custom_serializer(event) + # User-supplied callable; mypy can't narrow its return. + return self.custom_serializer(event) # type: ignore[no-any-return] # Use Pydantic's model_dump data = event.model_dump()