feat: refactor backend structure with AppBuilder and AgentService for improved dependency management

This commit is contained in:
2026-06-12 18:37:28 +08:00
parent 2e37587bd7
commit 61d2e511f6
6 changed files with 241 additions and 78 deletions
+69 -27
View File
@@ -1,4 +1,6 @@
"""FastAPI application factory."""
"""FastAPI application factory — encapsulated in an AppBuilder class."""
from __future__ import annotations
from contextlib import asynccontextmanager
@@ -6,36 +8,76 @@ import logfire
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from backend.core.config import Settings
from backend.core.dependencies import get_agent_service
from backend.routes.chat import router as chat_router
from backend.routes.health import router as health_router
@asynccontextmanager
async def lifespan(_app: FastAPI):
"""Startup / shutdown lifecycle."""
logfire.info("Agent Alpha backend starting")
yield
logfire.info("Agent Alpha backend shutting down")
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.
"""
def __init__(self, settings: Settings | None = None) -> None:
from backend.core.config import settings as _default_settings
self._settings = settings or _default_settings
# ── Factory ────────────────────────────────────────────────────────────
def build(self) -> FastAPI:
"""Construct the fully-configured FastAPI application."""
app = FastAPI(
title="Agent Alpha",
description="Agentic AI backend powered by pydantic-ai",
version="0.1.0",
lifespan=self._lifespan,
)
# Allow the frontend dev-server to call the API.
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health_router)
app.include_router(chat_router)
return app
# ── Lifecycle ──────────────────────────────────────────────────────────
@asynccontextmanager
async def _lifespan(self, _app: FastAPI):
"""Startup / shutdown lifecycle."""
logfire.info("Agent Alpha backend starting")
# Initialise the agent service on startup.
agent_service = await get_agent_service()
agent_service.initialize()
yield
# Graceful teardown on shutdown.
await agent_service.shutdown()
logfire.info("Agent Alpha backend shuttingdown")
# ---------------------------------------------------------------------------
# Module-level factory function (backward-compatible).
# ---------------------------------------------------------------------------
_app_builder: AppBuilder | None = None
def create_app() -> FastAPI:
app = FastAPI(
title="Agent Alpha",
description="Agentic AI backend powered by pydantic-ai",
version="0.1.0",
lifespan=lifespan,
)
# Allow the frontend dev-server to call the API.
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health_router)
app.include_router(chat_router)
return app
"""Return a new FastAPI application instance."""
global _app_builder
_app_builder = AppBuilder()
return _app_builder.build()
+97 -41
View File
@@ -1,10 +1,12 @@
"""Agent lifecycle: builds the shared agent instance loaded by the API routes."""
from __future__ import annotations
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, Thinking, ToolSearch, WebSearch
from pydantic_ai.models.openai import OpenAIResponsesModel
from pydantic_ai.providers.openai import OpenAIProvider
from pydantic_ai_harness import CodeMode
# Community packages, alphabetical:
@@ -17,49 +19,103 @@ from pydantic_deep import MemoryCapability, StuckLoopDetection
from pydantic_deep.deps import DeepAgentDeps
from subagents_pydantic_ai import SubAgentCapability, SubAgentConfig
from backend.core.config import settings
from backend.core.config import Settings
logfire.configure()
logfire.instrument_pydantic_ai()
model = OpenAIResponsesModel(
settings.llm_model,
provider=OpenAIProvider(base_url=settings.llm_base_url),
)
class AgentService:
"""Encapsulates agent construction, execution, and teardown.
agent = Agent(
model,
capabilities=[
CodeMode(),
ToolSearch(),
Thinking(effort="xhigh"),
ContextManagerCapability(max_tokens=100_000),
MCP("https://hn.caseyjhand.com/mcp"),
WebSearch(),
ConsoleCapability(),
MemoryCapability(agent_name="harness-example"),
SkillsCapability(directories=["./skills"]),
SubAgentCapability(
subagents=[
SubAgentConfig(
name="researcher",
description="Deep research on a topic",
instructions="You are a thorough research assistant.",
),
]
),
TodoCapability(enable_subtasks=True),
CostTracking(budget_usd=5.0),
InputGuard(guard=lambda p: "ignore previous instructions" not in p.lower()),
ToolGuard(blocked=["rm"], require_approval=["write_file"]),
SecretRedaction(),
StuckLoopDetection(),
],
)
Uses lazy initialization so the agent is not built at import time.
Accepts a ``Settings`` instance for testability (dependency injection).
"""
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._agent: Agent | None = None
# ── Lifecycle ──────────────────────────────────────────────────────────
def initialize(self) -> None:
"""Build the agent. Idempotent — safe to call multiple times."""
if self._agent is not None:
return
logfire.configure()
logfire.instrument_pydantic_ai()
model = OpenAIResponsesModel(
self._settings.llm_model,
provider=OpenAIProvider(base_url=self._settings.llm_base_url),
)
self._agent = Agent(
model,
capabilities=self._build_capabilities(),
)
async def shutdown(self) -> None:
"""Release agent resources."""
self._agent = None
# ── Execution ──────────────────────────────────────────────────────────
async def ask(self, prompt: str, session_id: str | None = None) -> str:
"""Send a prompt to the agent and return the text output."""
self.initialize()
assert self._agent is not None
deps = DeepAgentDeps()
result = await self._agent.run(prompt, deps=deps)
return result.output
# ── Internal helpers ───────────────────────────────────────────────────
@staticmethod
def _build_capabilities() -> list:
"""Assemble the full list of agent capabilities."""
return [
CodeMode(),
ToolSearch(),
Thinking(effort="xhigh"),
ContextManagerCapability(max_tokens=100_000),
MCP("https://hn.caseyjhand.com/mcp"),
WebSearch(),
ConsoleCapability(),
MemoryCapability(agent_name="harness-example"),
SkillsCapability(directories=["./skills"]),
SubAgentCapability(
subagents=[
SubAgentConfig(
name="researcher",
description="Deep research on a topic",
instructions="You are a thorough research assistant.",
),
]
),
TodoCapability(enable_subtasks=True),
CostTracking(budget_usd=5.0),
InputGuard(guard=lambda p: "ignore previous instructions" not in p.lower()),
ToolGuard(blocked=["rm"], require_approval=["write_file"]),
SecretRedaction(),
StuckLoopDetection(),
]
# ---------------------------------------------------------------------------
# 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)
def get_service() -> AgentService:
"""Return the singleton AgentService instance."""
return _service
async def ask(prompt: str, session_id: str | None = None) -> str:
"""Send a prompt to the agent and return the text output."""
deps = DeepAgentDeps()
result = await agent.run(prompt, deps=deps)
return result.output
return await _service.ask(prompt, session_id=session_id)
+23 -1
View File
@@ -1,8 +1,18 @@
"""Application settings loaded from .env via pydantic-settings."""
from __future__ import annotations
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application settings loaded from .env / environment."""
"""Application settings loaded from .env / environment.
Usage::
settings = Settings() # load from .env / env vars
settings = Settings(_env_file=".env.test") # alternate file
"""
model_config = SettingsConfigDict(
env_file=".env",
@@ -13,5 +23,17 @@ class Settings(BaseSettings):
llm_base_url: str = "http://localhost:11434/v1"
llm_model: str = "llama"
# ── Factories ──────────────────────────────────────────────────────────
@classmethod
def create(cls, **overrides: str) -> Settings:
"""Return a Settings instance with selective overrides (useful in tests).
Example::
settings = Settings.create(llm_model="test-model")
"""
return cls(**overrides)
settings = Settings()
+25
View File
@@ -0,0 +1,25 @@
"""FastAPI dependency injection providers."""
from __future__ import annotations
from typing import TYPE_CHECKING
from backend.core.agent import AgentService
if TYPE_CHECKING:
from backend.core.config import Settings
async def get_settings() -> Settings:
"""Provide the application Settings singleton."""
from backend.core.config import Settings as _Settings
from backend.core.config import settings as _settings
return _settings
async def get_agent_service() -> AgentService:
"""Provide the lazily-initialised AgentService singleton."""
from backend.core.agent import get_service
return get_service()
+16 -4
View File
@@ -1,10 +1,22 @@
"""Entry point — run with `uv run python -m backend.main` or `uvicorn backend.app:create_app`."""
"""Entry point — run with ``uv run python -m backend.main``.
The FastAPI app is created lazily inside ``if __name__`` so importing
this module has no side effects.
"""
import uvicorn
from backend.app import create_app
app = create_app()
def main() -> None:
"""Start the Uvicorn development server."""
uvicorn.run(
"backend.app:create_app",
host="0.0.0.0",
port=8000,
reload=True,
factory=True,
)
if __name__ == "__main__":
uvicorn.run("backend.app:create_app", host="0.0.0.0", port=8000, reload=True, factory=True)
main()
+11 -5
View File
@@ -1,9 +1,12 @@
"""Chat API endpoint — streams agent responses using SSE."""
"""Chat API endpoint — sends prompts to the agent and returns replies."""
from fastapi import APIRouter, HTTPException
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from backend.core.agent import ask
from backend.core.agent import AgentService
from backend.core.dependencies import get_agent_service
router = APIRouter(prefix="/api/chat", tags=["chat"])
@@ -19,10 +22,13 @@ class ChatResponse(BaseModel):
@router.post("", response_model=ChatResponse)
async def chat_endpoint(body: ChatRequest) -> ChatResponse:
async def chat_endpoint(
body: ChatRequest,
agent: AgentService = Depends(get_agent_service),
) -> ChatResponse:
"""Send a user message to the agent and return its reply."""
try:
output = await ask(body.message, session_id=body.session_id)
output = await agent.ask(body.message, session_id=body.session_id)
return ChatResponse(reply=output, session_id=body.session_id)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))