Merge pull request #1 from furyhawk/refactor

Refactor agent initialization and enhance capabilities and docs
This commit is contained in:
2026-06-16 21:14:21 +08:00
committed by GitHub
10 changed files with 164 additions and 136 deletions
+4 -1
View File
@@ -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.
+8 -6
View File
@@ -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
+13 -5
View File
@@ -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
+21 -120
View File
@@ -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
+19
View File
@@ -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")
+72
View File
@@ -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(),
],
)
+20
View File
@@ -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"),
]
+5 -2
View File
@@ -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;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "agent-alpha-frontend",
"private": true,
"version": "0.3.0",
"version": "0.4.0",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -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"