From 61d2e511f6495260135b512b89889378242892c8 Mon Sep 17 00:00:00 2001 From: furyhawk Date: Fri, 12 Jun 2026 18:37:28 +0800 Subject: [PATCH] feat: refactor backend structure with AppBuilder and AgentService for improved dependency management --- backend/app.py | 96 +++++++++++++++++------- backend/core/agent.py | 138 ++++++++++++++++++++++++----------- backend/core/config.py | 24 +++++- backend/core/dependencies.py | 25 +++++++ backend/main.py | 20 ++++- backend/routes/chat.py | 16 ++-- 6 files changed, 241 insertions(+), 78 deletions(-) create mode 100644 backend/core/dependencies.py diff --git a/backend/app.py b/backend/app.py index 2f1cdb5..a53bc3d 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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() diff --git a/backend/core/agent.py b/backend/core/agent.py index 3574d68..4d78aa9 100644 --- a/backend/core/agent.py +++ b/backend/core/agent.py @@ -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) diff --git a/backend/core/config.py b/backend/core/config.py index 0ca970b..f09f0b6 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -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() diff --git a/backend/core/dependencies.py b/backend/core/dependencies.py new file mode 100644 index 0000000..77d3d67 --- /dev/null +++ b/backend/core/dependencies.py @@ -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() diff --git a/backend/main.py b/backend/main.py index 177b0c3..a9caad9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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() diff --git a/backend/routes/chat.py b/backend/routes/chat.py index b669311..63bc0a3 100644 --- a/backend/routes/chat.py +++ b/backend/routes/chat.py @@ -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))