mirror of
https://github.com/furyhawk/agent_alpha.git
synced 2026-07-21 02:25:34 +00:00
feat: refactor backend structure with AppBuilder and AgentService for improved dependency management
This commit is contained in:
+69
-27
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user