- Event-based streaming for task agent - Retry logic when LLM responds without calling tools - Hardened prompts to enforce tool use - Working directory context in all agent prompts - Project paused: local LLMs not capable enough for agentic use Co-Authored-By: Claude Opus 4.5 <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 BaseAgent, AgentContext, 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 logged, get_logger, 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):
|
|
"""
|
|
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: Agent[ExploreContext, str] | None = 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
|