diff --git a/CLAUDE.md b/CLAUDE.md index db0eaa1..72aa786 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,15 +31,18 @@ Agent Alpha is a full-stack agentic AI application consisting of a FastAPI backe ### Backend Architecture (`/backend`) The backend follows a layered architecture: - **Core (`/backend/core`)**: The heart of the application. Contains configuration (`config.py`), database engine initialization (`database.py`), ORM models (`models.py`), and the primary agent lifecycle/inference logic (`agent.py`). + - `AgentService` is a thin orchestrator that receives a pre-built `Agent` via constructor injection (wired at startup in `app.py`'s lifespan). - **Database & Repositories**: - **Models**: SQL Alchemy ORM models are split between general domain models (in `core`) and RAG-specific models (in `db/models` like `ChatFile`, `RagDocument`). - - **Repositories**: Abstracted CRUD operations for all DB models are located in `/backend/repositories`. + - **Repositories**: Abstracted CRUD operations for all DB models are located in `/backend/repositories`. `MemoryRepository` encapsulates filesystem persistence for agent memories via `LocalBackend`. - **RAG Pipeline (`/backend/rag`)**: Handles the document ingestion lifecycle. - `connectors.py` manages sync sources. - `ingestion.py` manages the Parse → Chunk → Embed → Store pipeline. - `retrieval.py` and `reranker.py` handle multi-stage vector search and scoring. - `vectorstore.py` interfaces with Milvus. - **Services**: Domain-specific logic for file storage, RAG tracking, status streaming (via Redis/SSE), and synchronization. + - `agent_factory.py` provides `build_agent()` — a factory that wires model providers, Logfire, capabilities (`CodeMode`, `ToolSearch`, `MCP`, `WebSearch`, `InputGuard`, `ToolGuard`), subagents, skills, and RAG tools into a `pydantic-ai` agent. + - `rag_service.py` provides `RagService` which builds RAG search tools for document retrieval. - **Routes & Schemas**: FastAPI endpoints (`/routes`) are paired with Pydantic models (`/schemas`) for request validation and response serialization. - **Worker**: Handles asynchronous tasks (like heavy RAG ingestion) via an in-process dispatcher, designed to eventually move to a Redis-backed ARQ setup. diff --git a/agents.md b/agents.md index 3e6f157..cb2f736 100644 --- a/agents.md +++ b/agents.md @@ -9,23 +9,25 @@ Agents are built using the `pydantic-ai` framework. Every agent must be modulari ### 1. Core Agent (`backend/core/`) The core module should only handle high-level agent lifecycle and basic configuration. It is a "Thin Service" that orchestrates the interaction between the user request, the dependencies, and the LLM. -### 2. Factory Layer (`backend/services/factory/`) -Complex logic for initializing agents—including model provider setup, logfire integration, tool registration, and environment variable validation—must reside in specialized factories (e.g., `AgentFactory`). +### 2. Factory Layer (`backend/services/agent_factory.py`) +Complex logic for initializing agents—including model provider setup, Logfire integration, capability registration (`CodeMode`, `ToolSearch`, `MCP`, `WebSearch`, guardrails), subagent setup, skills discovery, and RAG tool injection—resides in the `build_agent()` factory function. ### 3. Service Layer (`backend/services/`) Business logic related to agent capabilities must be moved to dedicated services: -- **RAG Service**: Handles the construction of retrieval tools and interaction with vector stores. -- **Skill Service**: Manages the discovery, registration, and execution of modular "Skills". -- **Memory Service**: Orchestrates persistence of conversation history and state. +- **Agent Factory** (`agent_factory.py`): `build_agent()` wires model, capabilities, subagents, skills, and RAG tools into a configured `pydantic-ai` agent. +- **RAG Service** (`rag_service.py`): Constructs retrieval tools for vector search (by document, by collection) and registers them as agent tools. +- **Chat Service** (`chat_service.py`): Manages conversation history, session metadata, and title generation via Valkey. +- **Auth Service** (`auth_service.py`): Handles user registration, password hashing, login, and token lifecycle. ### 4. Repository Layer (`backend/repositories/`) All direct interactions with data sources (SQLAlchemy models, Milvus vector store, local filesystems) must be abstracted into repositories. - Repositories are responsible for CRUD operations and state persistence. - Repositories use `db.flush()` to ensure data integrity without premature commits in transactional blocks. +- **MemoryRepository** (`memory_repository.py`): Encapsulates agent memory filesystem persistence via `LocalBackend`; provides both the memory directory path and the backend instance for agent `RunContext`. ## Tooling & Skills - **Tools**: Must be decorated with `@agent.tool`. Descriptions must be highly descriptive for LLM comprehension. -- **Skills**: Modular capabilities stored in `/skills/`. These are registered by the `SkillService` and injected into agents as tools. +- **Skills**: Modular capabilities stored in `/skills/`. These are automatically discovered and registered by `create_deep_agent` when `include_skills=True` and `skill_directories=["./skills"]` are set in the factory. - **Dependencies**: Use FastAPI's Dependency Injection system to provide Repositories and Services to Agent logic via `RunContext`. ## Patterns diff --git a/backend/app.py b/backend/app.py index 69ddbac..b604f71 100644 --- a/backend/app.py +++ b/backend/app.py @@ -11,7 +11,10 @@ from uvicorn.logging import DefaultFormatter from backend.core.config import Settings from backend.core.database import close_engine, close_valkey, init_db -from backend.core.dependencies import get_agent_service +from backend.core.agent import AgentService, set_service +from backend.repositories.memory_repository import MemoryRepository +from backend.services.agent_factory import build_agent +from backend.services.rag_service import RagService from backend.routes.admin import router as admin_router from backend.routes.auth import router as auth_router from backend.routes.chat import router as chat_router @@ -60,7 +63,9 @@ class AppBuilder: """Builds and configures the FastAPI application with DI wiring. Accepts a ``Settings`` instance so tests can supply custom config. - The ``AgentService`` is lazily initialised inside the lifespan. + The ``AgentService`` is built inside the lifespan hook, which wires + ``MemoryRepository``, ``RagService``, and the agent factory together + and registers the result as a module-level singleton. """ def __init__(self, settings: Settings | None = None) -> None: @@ -110,9 +115,12 @@ class AppBuilder: # Create database tables if they don't exist. await init_db() - # Initialise the agent service on startup. - agent_service = await get_agent_service() - agent_service.initialize() + # Build agent with full dependency injection via the factory. + memory_repo = MemoryRepository() + rag_service = RagService() + agent = build_agent(self._settings, memory_repo, rag_service) + agent_service = AgentService(agent=agent, memory_repo=memory_repo) + set_service(agent_service) yield diff --git a/backend/core/agent.py b/backend/core/agent.py index 7b3f70e..acc9380 100644 --- a/backend/core/agent.py +++ b/backend/core/agent.py @@ -27,97 +27,28 @@ from __future__ import annotations import time from dataclasses import dataclass +from typing import Any -import logfire from pydantic_ai import Agent -from pydantic_ai.capabilities import MCP, ToolSearch, WebSearch -from pydantic_ai.models.openai import OpenAIResponsesModel -from pydantic_ai.providers.openai import OpenAIProvider -from pydantic_ai.tools import Tool -from pydantic_ai_harness import CodeMode -# Community packages, alphabetical: -from pydantic_ai_backends import LocalBackend -from pydantic_ai_shields import InputGuard, SecretRedaction, ToolGuard -from pydantic_deep import create_deep_agent +from backend.repositories.memory_repository import MemoryRepository from pydantic_deep.deps import DeepAgentDeps -from subagents_pydantic_ai import SubAgentConfig - -from backend.core.config import Settings class AgentService: - """Encapsulates agent construction, execution, and teardown. + """Thin orchestrator for agent execution. - Uses lazy initialization so the agent is not built at import time. - Accepts a ``Settings`` instance for testability (dependency injection). + Receives a pre-built ``Agent`` and its ``MemoryRepository`` via + constructor injection so the lifespan (or tests) control wiring. + No lazy initialisation — the agent is ready to run immediately. """ - def __init__(self, settings: Settings) -> None: - self._settings = settings - self._agent: Agent[DeepAgentDeps, str] | None = None - self._model: OpenAIResponsesModel | None = None + def __init__(self, agent: Agent[Any, str], memory_repo: MemoryRepository) -> None: + self._agent = agent + self._memory_repo = memory_repo # ── Lifecycle ────────────────────────────────────────────────────────── - def initialize(self) -> None: - """Build the agent. Idempotent — safe to call multiple times.""" - if self._agent is not None: - return - - if self._settings.logfire_token: - try: - logfire.configure(token=self._settings.logfire_token) - logfire.instrument_pydantic_ai() - except Exception: - pass # Logfire is optional — don't crash if it fails - - self._model = OpenAIResponsesModel( - self._settings.llm_model, - provider=OpenAIProvider( - base_url=self._settings.llm_base_url, - api_key=self._settings.llm_api_key or "no-key-required", - ), - ) - - self._agent = create_deep_agent( - model=self._model, - include_todo=True, - include_filesystem=True, - include_subagents=True, - include_skills=True, - include_plan=True, - include_execute=False, - include_memory=True, - memory_dir=_MEMORY_DIR, - web_search=False, - web_fetch=True, - thinking="xhigh", - context_manager=True, - context_manager_max_tokens=100_000, - cost_tracking=True, - cost_budget_usd=5.0, - stuck_loop_detection=True, - subagents=[ - SubAgentConfig( - name="researcher", - description="Deep research on a topic", - instructions="You are a thorough research assistant.", - ), - ], - skill_directories=["./skills"], - tools=self._build_rag_tools(), - capabilities=[ - CodeMode(), - ToolSearch(), - MCP("https://hn.caseyjhand.com/mcp", native=True), - WebSearch(local="duckduckgo"), - InputGuard(guard=lambda p: "ignore previous instructions" not in p.lower()), - ToolGuard(blocked=["rm"], require_approval=["write_file"]), - SecretRedaction(), - ], - ) - async def shutdown(self) -> None: """Release agent resources.""" self._agent = None @@ -126,13 +57,13 @@ class AgentService: async def ask(self, prompt: str, session_id: str | None = None) -> AskResult: """Send a prompt to the agent and return the text output with usage stats.""" - self.initialize() - assert self._agent is not None - deps = DeepAgentDeps(backend=_backend) + di = DeepAgentDeps(backend=self._memory_repo.backend) + t0 = time.monotonic() - result = await self._agent.run(prompt, deps=deps) + result = await self._agent.run(prompt, deps=di) elapsed = time.monotonic() - t0 usage = result.usage + return AskResult( output=result.output, input_tokens=usage.input_tokens, @@ -141,24 +72,6 @@ class AgentService: elapsed_seconds=round(elapsed, 2), ) - # ── Internal helpers ─────────────────────────────────────────────────── - - @staticmethod - def _build_rag_tools() -> list[Tool]: - """Build RAG search tools for document retrieval.""" - from skills.rag_search.search import rag_search, rag_search_by_document, rag_list_collections - - return [ - Tool(rag_search, name="rag_search"), - Tool(rag_search_by_document, name="rag_search_by_document"), - Tool(rag_list_collections, name="rag_list_collections"), - ] - - -# --------------------------------------------------------------------------- -# AskResult — returned by AgentService.ask() with output + token usage stats. -# --------------------------------------------------------------------------- - @dataclass class AskResult: @@ -181,32 +94,20 @@ class AskResult: # --------------------------------------------------------------------------- -# Persistent backend — uses local filesystem so agent memory survives restarts. +# Singleton — wired once at startup by the lifespan hook in ``app.py``. +# Routes access it through ``get_service()``. # --------------------------------------------------------------------------- -import os as _os - -_AGENT_ROOT = _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))) -_MEMORY_DIR = _os.path.join(_AGENT_ROOT, ".agent_memory") - -_backend = LocalBackend(root_dir=_AGENT_ROOT) - -# --------------------------------------------------------------------------- -# Singleton — lazy-initialised on the first call to ``ask()``. -# Keeps the existing module-level ``ask`` facade so downstream imports in -# ``chat.py`` continue to work unchanged. -# --------------------------------------------------------------------------- - -from backend.core.config import settings as _settings # noqa: E402 - -_service = AgentService(_settings) +_service: AgentService | None = None def get_service() -> AgentService: """Return the singleton AgentService instance.""" + assert _service is not None, "AgentService has not been initialised — did the lifespan run?" return _service -async def ask(prompt: str, session_id: str | None = None) -> AskResult: - """Send a prompt to the agent and return the text output with usage stats.""" - return await _service.ask(prompt, session_id=session_id) +def set_service(service: AgentService) -> None: + """Set the module-level singleton (called by the lifespan hook).""" + global _service + _service = service diff --git a/backend/repositories/memory_repository.py b/backend/repositories/memory_repository.py new file mode 100644 index 0000000..22856aa --- /dev/null +++ b/backend/repositories/memory_repository.py @@ -0,0 +1,19 @@ +import os as _os +from pydantic_ai_backends import LocalBackend + +class MemoryRepository: + """Handles local filesystem persistence for agent memory.""" + + def __init__(self) -> None: + # In /backend/repositories/memory_repository.py, + # the project root is 3 levels up from this file. + self._agent_root = _os.path.dirname(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))) + self._backend = LocalBackend(root_dir=self._agent_root) + + @property + def backend(self) -> LocalBackend: + return self._backend + + @property + def memory_dir(self) -> str: + return _os.path.join(self._agent_root, ".agent_memory") diff --git a/backend/services/agent_factory.py b/backend/services/agent_factory.py new file mode 100644 index 0000000..48766e5 --- /dev/null +++ b/backend/services/agent_factory.py @@ -0,0 +1,72 @@ +from typing import List +import logfire +from pydantic_ai.models.openai import OpenAIResponsesModel +from pydantic_ai.providers.openai import OpenAIProvider +from pydantic_ai import Agent +from pydantic_ai.capabilities import MCP, ToolSearch, WebSearch +from pydantic_ai_harness import CodeMode +from pydantic_deep import create_deep_agent +from subagents_pydantic_ai import SubAgentConfig +from pydantic_ai_shields import InputGuard, SecretRedaction, ToolGuard + +from backend.core.config import Settings +from backend.repositories.memory_repository import MemoryRepository +from backend.services.rag_service import RagService + +def build_agent(settings: Settings, memory_repository: MemoryRepository, rag_service: RagService) -> Agent: + """ + Factory method to create the PydanticDeep agent. + Moves initialization logic from AgentService into this factory. + """ + if settings.logfire_token: + try: + logfire.configure(token=settings.logfire_token) + logfire.instrument_pydantic_ai() + except Exception: + pass # Logfire is optional — don't crash if it fails + + model = OpenAIResponsesModel( + settings.llm_model, + provider=OpenAIProvider( + base_url=settings.llm_base_url, + api_key=settings.llm_api_key or "no-key-required", + ), + ) + + return create_deep_agent( + model=model, + include_todo=True, + include_filesystem=True, + include_subagents=True, + include_skills=True, + include_plan=True, + include_execute=False, + include_memory=True, + memory_dir=memory_repository.memory_dir, + web_search=False, + web_fetch=True, + thinking="xhigh", + context_manager=True, + context_manager_max_tokens=100_000, + cost_tracking=True, + cost_budget_usd=5.0, + stuck_loop_detection=True, + subagents=[ + SubAgentConfig( + name="researcher", + description="Deep research on a topic", + instructions="You are a thorough research assistant.", + ), + ], + skill_directories=["./skills"], + tools=rag_service.get_rag_tools(), + capabilities=[ + CodeMode(), + ToolSearch(), + MCP("https://hn.caseyjhand.com/mcp", native=True), + WebSearch(local="duckduckgo"), + InputGuard(guard=lambda p: "ignore previous instructions" not in p.lower()), + ToolGuard(blocked=["rm"], require_approval=["write_file"]), + SecretRedaction(), + ], + ) diff --git a/backend/services/rag_service.py b/backend/services/rag_service.py new file mode 100644 index 0000000..a73cf1d --- /dev/null +++ b/backend/services/rag_service.py @@ -0,0 +1,20 @@ +from pydantic_ai.tools import Tool +from typing import List + +# These are imported from the existing skills structure +try: + from skills.rag_search.search import rag_search, rag_search_by_document, rag_list_collections +except ImportError: + # Fallback if the package isn't installed but we want to maintain the code structure + pass + +class RagService: + """Handles RAG tool construction.""" + + def get_rag_tools(self) -> List[Tool]: + """Builds and returns RAG search tools for document retrieval.""" + return [ + Tool(rag_search, name="rag_search"), + Tool(rag_search_by_document, name="rag_search_by_document"), + Tool(rag_list_collections, name="rag_list_collections"), + ] diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 3abb0ae..ee98d5a 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -12,8 +12,11 @@ server { # ── API proxy ───────────────────────────────────────────────── # Container runtime (Docker/Podman) resolves backend:8000 via - # its built-in DNS. nginx caches the resolved IP at startup, so - # restarting the backend container needs a frontend restart too. + # its built-in DNS (127.0.0.11). Without a resolver, nginx caches + # the resolved IP at startup and returns 502 when the backend + # container is recreated. The resolver + valid=5s forces periodic + # re-resolution so the frontend survives backend restarts. + resolver 127.0.0.11 valid=5s; location /api/ { proxy_pass http://backend:8000; proxy_set_header Host $host; diff --git a/frontend/package.json b/frontend/package.json index fdce2c0..6ce0c8e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "agent-alpha-frontend", "private": true, - "version": "0.3.0", + "version": "0.4.0", "type": "module", "scripts": { "dev": "vite", diff --git a/pyproject.toml b/pyproject.toml index d144f9b..1c3a1e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-alpha" -version = "0.3.0" +version = "0.4.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.12"