55 errors to zero. Nearly all of them traced back to two causes rather than 55. THE DECORATOR. @logged wraps ~24 functions across this package and was declared `def decorator(func: Callable):` with no ParamSpec and no return annotation, so it erased the signature of everything it touched. ToolResult.execute() is annotated `-> ToolResult`; through the decorator it came back Any, and mypy reported 33 no-any-return errors spread across the tools and agents. Each looked like a local annotation slip. All of them were one decorator. Typed with ParamSpec/TypeVar; the async branch casts at the await rather than loosening R, because loosening R would put the Any straight back into every caller. THE MISSING TYPE PARAMETER. BaseAgent was not generic, so _create_agent returned a bare Agent — Agent[Any, Any] — and pydantic_ai then typed every run() result as Any. BaseAgent is now Generic[CtxT] bound to AgentContext, _agent is declared on the base instead of reached through hasattr, and the three tool-registration functions take their agent's real context type. tools_streaming.py already did this; the other three had not been updated. Eight `execute` overrides carry a targeted ignore rather than a package-wide disable_error_code. Every tool narrows the base's **kwargs to its own named parameters, which is a real LSP violation — but nothing anywhere is typed as BaseTool, and every call site constructs the concrete tool. The abstract method earns its place by making a tool without execute impossible to instantiate. The reasoning lives in BaseTool.execute's docstring; the per-site suppressions mean an override that IS unsound still gets caught. BaseAgent.run_stream widened to AsyncIterator[str | StreamEvent], which is what callers already receive: task streams structured events, explore and plan stream strings, and the router branches on isinstance with a comment calling the string path legacy. The annotation now says what the code does. AND THE PART THAT MATTERS MORE THAN THE TYPES. Chasing the last error found that the Ollama sanitiser has been broken. It fetched the parent's chat getter with `AsyncOpenAI.chat.fget`, and openai made `chat` a functools.cached_property, whose getter is `.func`. Touching `.chat` raised AttributeError — meaning the content: null workaround that CLAUDE.md documents as live would have failed on the first completion any agent attempted. Confirmed in the running container (openai 2.46.0) as well as locally (2.15.0). Two things hid it. The line carried a bare `# type: ignore`, which suppressed precisely the complaint that would have caught it. And /agents/run and /agents/stream have served zero requests in 30 days, so nothing exercised the path. A mitigation can rot completely while every check stays green, if no check actually runs it. The lookup now reads whichever getter the descriptor exposes and raises a legible TypeError if openai adopts a third shape. tests/test_ollama_provider.py walks the chain an agent request walks, short of the network call — mutation-checked: all four fail against the old lookup. 215 passed, 23 skipped, plus the four new. mypy clean over 90 files. Co-Authored-By: Claude <noreply@anthropic.com>
183 lines
5.6 KiB
Python
183 lines
5.6 KiB
Python
"""
|
|
Explore Agent implementation using PydanticAI.
|
|
|
|
Fast codebase exploration with read-only tools.
|
|
Uses sanitized Ollama provider for reliable tool calling.
|
|
"""
|
|
import os
|
|
from collections.abc import AsyncIterator
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from pydantic_ai import Agent
|
|
from pydantic_ai.models.openai import OpenAIModel
|
|
|
|
from src.domains.agents.base import AgentContext, BaseAgent, register_agent
|
|
from src.domains.agents.explore.prompts import EXPLORE_SYSTEM_PROMPT
|
|
from src.ollama.provider import get_ollama_provider
|
|
from src.shared.config import get_settings
|
|
from src.shared.logging import get_logger, logged, trace_span
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class ExploreContext(AgentContext):
|
|
"""
|
|
Context for explore agent tools.
|
|
|
|
Passed to all tool functions via RunContext.
|
|
"""
|
|
pass
|
|
|
|
|
|
class ExploreAgentImpl(BaseAgent[ExploreContext]):
|
|
"""
|
|
Fast codebase exploration agent.
|
|
|
|
Uses glob, grep, read, and bash tools to search and analyze codebases.
|
|
Read-only mode - cannot modify files.
|
|
"""
|
|
|
|
name = "explore"
|
|
description = "Fast codebase exploration - find files, search content, read code"
|
|
|
|
def __init__(self):
|
|
"""Initialize the explore agent."""
|
|
self._agent = None
|
|
self._settings = get_settings()
|
|
|
|
def _create_agent(self) -> Agent[ExploreContext, str]:
|
|
"""Create the PydanticAI agent with Ollama backend."""
|
|
# Use sanitized Ollama provider to fix content: null issues
|
|
model = OpenAIModel(
|
|
model_name=self._settings.ollama_agent_model,
|
|
provider=get_ollama_provider(),
|
|
)
|
|
|
|
agent: Agent[ExploreContext, str] = Agent(
|
|
model=model,
|
|
system_prompt=EXPLORE_SYSTEM_PROMPT,
|
|
deps_type=ExploreContext,
|
|
output_type=str,
|
|
# Mistral Nemo settings:
|
|
# - temperature 0.3 (Nemo needs slightly higher than 0.0)
|
|
# - tool_choice "required" forces tool use
|
|
model_settings={
|
|
"temperature": 0.3,
|
|
"extra_body": {"tool_choice": "required"},
|
|
},
|
|
)
|
|
|
|
# Register tools
|
|
self._register_tools(agent)
|
|
|
|
return agent
|
|
|
|
def _register_tools(self, agent: Agent[ExploreContext, str]) -> None:
|
|
"""Register all exploration tools with the agent."""
|
|
from src.domains.agents.explore.tools import register_explore_tools
|
|
register_explore_tools(agent)
|
|
|
|
def _build_prompt_with_context(self, prompt: str, working_dir: str) -> str:
|
|
"""Build the prompt with working directory context."""
|
|
return f"""Working directory: {working_dir}
|
|
|
|
Use paths within this working directory for file operations.
|
|
|
|
User request: {prompt}"""
|
|
|
|
@logged()
|
|
async def run(
|
|
self,
|
|
prompt: str,
|
|
working_dir: str | None = None,
|
|
allowed_paths: list[str] | None = None,
|
|
**kwargs: Any
|
|
) -> str:
|
|
"""
|
|
Run the explore agent with a prompt.
|
|
|
|
Args:
|
|
prompt: User query about the codebase
|
|
working_dir: Working directory for exploration
|
|
allowed_paths: Restrict tool access to these paths
|
|
|
|
Returns:
|
|
Agent response with findings
|
|
"""
|
|
effective_working_dir = working_dir or os.getcwd()
|
|
|
|
ctx = ExploreContext(
|
|
working_dir=effective_working_dir,
|
|
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
|
timeout_seconds=self._settings.tool_timeout_seconds,
|
|
)
|
|
|
|
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
|
|
|
|
async with trace_span("explore_agent_run"):
|
|
try:
|
|
# Use run() not run_stream() - Ollama has bugs with streaming + tools
|
|
result = await self.agent.run(full_prompt, deps=ctx)
|
|
return result.output
|
|
except Exception as e:
|
|
logger.exception(f"Explore agent error: {e}")
|
|
raise
|
|
|
|
|
|
async def run_stream(
|
|
self,
|
|
prompt: str,
|
|
working_dir: str | None = None,
|
|
allowed_paths: list[str] | None = None,
|
|
**kwargs: Any
|
|
) -> AsyncIterator[str]:
|
|
"""
|
|
Run the explore agent with streaming output.
|
|
|
|
Yields text chunks as they become available.
|
|
"""
|
|
effective_working_dir = working_dir or os.getcwd()
|
|
|
|
ctx = ExploreContext(
|
|
working_dir=effective_working_dir,
|
|
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
|
timeout_seconds=self._settings.tool_timeout_seconds,
|
|
)
|
|
|
|
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
|
|
|
|
async with trace_span("explore_agent_stream"):
|
|
try:
|
|
async with self.agent.run_stream(full_prompt, deps=ctx) as result:
|
|
async for chunk in result.stream_text():
|
|
yield chunk
|
|
except Exception as e:
|
|
logger.exception(f"Explore agent stream error: {e}")
|
|
raise
|
|
|
|
|
|
# Create and register the singleton instance
|
|
explore_agent = ExploreAgentImpl()
|
|
register_agent(explore_agent)
|
|
|
|
|
|
async def explore(
|
|
prompt: str,
|
|
working_dir: str | None = None,
|
|
**kwargs: Any
|
|
) -> str:
|
|
"""Run exploration query."""
|
|
return await explore_agent.run(prompt, working_dir=working_dir, **kwargs)
|
|
|
|
|
|
async def explore_stream(
|
|
prompt: str,
|
|
working_dir: str | None = None,
|
|
**kwargs: Any
|
|
) -> AsyncIterator[str]:
|
|
"""Run exploration query with streaming."""
|
|
async for chunk in explore_agent.run_stream(prompt, working_dir=working_dir, **kwargs):
|
|
yield chunk
|