Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
quality:
runs-on: ubuntu-latest

services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: agent
POSTGRES_PASSWORD: localdev
POSTGRES_DB: agentdb_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U agent -d agentdb_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10

env:
OPENAI_API_KEY: sk-test-not-real
DATABASE_URL: postgresql://agent:localdev@localhost:5432/agentdb_test
POSTGRES_HOST: localhost
POSTGRES_PORT: 5432
POSTGRES_DB: agentdb_test
POSTGRES_USER: agent
POSTGRES_PASSWORD: localdev
APP_ENV: development
LOG_FORMAT: text

steps:
- name: Check out code
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"

- name: Run database migrations
run: alembic upgrade head

- name: Run Ruff
run: ruff check app/ tests/ scripts/

- name: Run MyPy
run: mypy app/ --ignore-missing-imports

- name: Run pytest
run: pytest -q --tb=short
Comment thread
IgnazioDS marked this conversation as resolved.
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,50 @@
# langgraph-fastapi-starter

[![CI](https://github.com/IgnazioDS/langgraph-fastapi-starter/actions/workflows/ci.yml/badge.svg)](https://github.com/IgnazioDS/langgraph-fastapi-starter/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/IgnazioDS/langgraph-fastapi-starter?display_name=tag)](https://github.com/IgnazioDS/langgraph-fastapi-starter/releases)

A production-grade backend template for AI agent applications. Fork this, configure
four environment variables, and you have a running agent API with auth, persistence,
and structured logging. Delete the example agent and build yours.

This is not a framework. It is an opinionated starting point that exposes every decision
so you can change the ones you disagree with.

## 10-Minute Path

If you want the fastest credible evaluation path, use this sequence:

1. Clone the repo and bring up Postgres with `make up`
2. Run migrations and create an API key
3. Change the graph in `app/graph/`
4. Send one real request to `/v1/agent/run`
5. Run lint, type-checking, and tests

The full walkthrough lives in [docs/build-your-first-agent.md](./docs/build-your-first-agent.md).

---

## Architecture

```mermaid
flowchart LR
Client["API client or UI"] --> Router["FastAPI routers"]
Router --> Auth["API key middleware"]
Auth --> Service["Agent service"]
Service --> Graph["LangGraph runtime"]
Graph --> Retrieve["retrieve_context node"]
Retrieve --> Model["call_model node"]
Model -->|tool calls| Tools["ToolNode(TOOLS)"]
Tools --> Model
Model --> Persistence["Session + message persistence"]
Persistence --> Postgres["PostgreSQL + pgvector"]
Retrieve --> Postgres
```

The request path is intentionally simple: FastAPI handles transport, middleware enforces
auth, the service layer invokes a small LangGraph loop, and PostgreSQL carries both
application data and vector-backed retrieval.

---

## What It Ships With
Expand Down Expand Up @@ -63,6 +101,21 @@ curl -X POST http://localhost:8000/v1/agent/run \

---

## Build Your Own Agent

You do not need to understand the whole repository before changing the agent. In most
cases, the first working customization fits inside four files:

- `app/graph/state.py`
- `app/graph/nodes.py`
- `app/graph/tools.py`
- `app/graph/graph.py`

For a concrete clone -> customize -> run -> test walkthrough, see
[docs/build-your-first-agent.md](./docs/build-your-first-agent.md).

---

## What to Change First

The starter ships with a research assistant agent. You will replace it.
Expand Down
1 change: 0 additions & 1 deletion app/db/connection.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from collections.abc import Generator
from contextlib import contextmanager

import psycopg2
from psycopg2.extensions import connection
from psycopg2.pool import ThreadedConnectionPool

Expand Down
4 changes: 3 additions & 1 deletion app/db/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@
# ─── Sessions ─────────────────────────────────────────────────────────────────

CREATE_SESSION = """
INSERT INTO agent_sessions (id, session_id, tenant_id, created_at, last_active_at, message_count)
INSERT INTO agent_sessions (
id, session_id, tenant_id, created_at, last_active_at, message_count
)
VALUES (%(id)s, %(session_id)s, %(tenant_id)s, NOW(), NOW(), 0)
ON CONFLICT (session_id, tenant_id) DO UPDATE SET last_active_at = NOW()
RETURNING id, session_id, created_at, last_active_at, message_count
Expand Down
8 changes: 4 additions & 4 deletions app/graph/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@
from app.graph.tools import TOOLS


def build_graph() -> CompiledStateGraph:
def build_graph() -> CompiledStateGraph[AgentState, None, AgentState, AgentState]:
"""Build and compile the agent graph.

Graph flow:
retrieve (fetch context) → agent (LLM call) → [tools? → agent] → END
"""
builder: StateGraph = StateGraph(AgentState)
builder: StateGraph[AgentState, None, AgentState, AgentState] = StateGraph(AgentState)

builder.add_node("retrieve", retrieve_context)
builder.add_node("agent", call_model)
Expand All @@ -41,10 +41,10 @@ def build_graph() -> CompiledStateGraph:


# Module-level compiled graph — initialized once at startup via init_graph().
_graph: CompiledStateGraph | None = None
_graph: CompiledStateGraph[AgentState, None, AgentState, AgentState] | None = None


def get_graph() -> CompiledStateGraph:
def get_graph() -> CompiledStateGraph[AgentState, None, AgentState, AgentState]:
"""Return the compiled graph. Raises if init_graph() has not been called."""
if _graph is None:
raise RuntimeError("Graph not initialized. Call init_graph() at startup.")
Expand Down
6 changes: 3 additions & 3 deletions app/graph/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import logging
from typing import Literal

from langchain_core.messages import SystemMessage
from langchain_core.messages import BaseMessage, SystemMessage
from langchain_openai import ChatOpenAI

from app.config import config
Expand All @@ -20,7 +20,7 @@
logger = logging.getLogger(__name__)


def retrieve_context(state: AgentState) -> dict:
def retrieve_context(state: AgentState) -> dict[str, list[str]]:
"""Retrieve relevant document chunks based on the latest user message.

Runs before the model call so retrieved context can be injected as a system message.
Expand Down Expand Up @@ -55,7 +55,7 @@ def retrieve_context(state: AgentState) -> dict:
return {"context": [result]}


def call_model(state: AgentState) -> dict:
def call_model(state: AgentState) -> dict[str, list[BaseMessage] | int]:
"""Invoke the LLM with the current message history and any retrieved context.

Binds all tools to the model so it can emit tool calls.
Expand Down
6 changes: 3 additions & 3 deletions app/graph/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import logging

from langchain_core.tools import tool
from langchain_core.tools import BaseTool, tool

from app.config import config

Expand All @@ -28,7 +28,7 @@ def web_search(query: str) -> str:
)

try:
from tavily import TavilyClient # type: ignore[import-untyped]
from tavily import TavilyClient

client = TavilyClient(api_key=config.tavily_api_key)
results = client.search(query=query, max_results=3)
Expand Down Expand Up @@ -100,4 +100,4 @@ def retrieve_documents(query: str, top_k: int = 5) -> str:


# All tools registered with the agent. Import this list in graph.py.
TOOLS: list = [web_search, retrieve_documents]
TOOLS: list[BaseTool] = [web_search, retrieve_documents]
16 changes: 15 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager

from fastapi import FastAPI, Request
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse

from app.config import config
Expand Down Expand Up @@ -53,6 +53,20 @@ def create_app() -> FastAPI:
app.include_router(agents_router)
app.include_router(keys_router)

@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
if isinstance(exc.detail, dict):
return JSONResponse(status_code=exc.status_code, content=exc.detail)

return JSONResponse(
status_code=exc.status_code,
content=ErrorResponse(
code="HTTP_ERROR",
message=str(exc.detail),
request_id=getattr(request.state, "request_id", None),
).model_dump(),
)

@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse:
logger.error(
Expand Down
6 changes: 3 additions & 3 deletions app/middleware/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import logging
import time
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime

from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
Expand Down Expand Up @@ -40,8 +40,8 @@ class _JsonFormatter(logging.Formatter):
"""

def format(self, record: logging.LogRecord) -> str:
payload: dict = {
"timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
payload: dict[str, object] = {
"timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(),
"level": record.levelname,
"logger": record.name,
"event": record.getMessage(),
Expand Down
9 changes: 5 additions & 4 deletions app/routers/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
# To support multiple graphs, pass a graph_name param and select in AgentService.

import logging
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, Request

Expand All @@ -26,7 +27,7 @@ def get_agent_service() -> AgentService:
async def run_agent(
request: RunAgentRequest,
http_request: Request,
agent_service: AgentService = Depends(get_agent_service),
agent_service: Annotated[AgentService, Depends(get_agent_service)],
) -> RunAgentResponse:
"""Run the agent for a single conversational turn.

Expand All @@ -53,14 +54,14 @@ async def run_agent(
message="Agent execution failed.",
request_id=request_id,
).model_dump(),
)
) from e


@router.get("/v1/agent/sessions/{session_id}", response_model=SessionResponse)
async def get_session(
session_id: str,
http_request: Request,
agent_service: AgentService = Depends(get_agent_service),
agent_service: Annotated[AgentService, Depends(get_agent_service)],
) -> SessionResponse:
"""Retrieve a session's message history."""
tenant_id: str = http_request.state.tenant_id
Expand Down Expand Up @@ -91,4 +92,4 @@ async def get_session(
message="Failed to retrieve session.",
request_id=request_id,
).model_dump(),
)
) from e
6 changes: 3 additions & 3 deletions app/routers/api_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ async def create_api_key(
message="Failed to create API key.",
request_id=request_id,
).model_dump(),
)
) from e


@router.delete("/v1/keys/{key_id}", status_code=204)
Expand Down Expand Up @@ -78,7 +78,7 @@ async def revoke_api_key(
message="Failed to revoke API key.",
request_id=request_id,
).model_dump(),
)
) from e


@router.get("/v1/keys", response_model=list[ApiKeyResponse])
Expand All @@ -99,4 +99,4 @@ async def list_api_keys(
message="Failed to list API keys.",
request_id=request_id,
).model_dump(),
)
) from e
15 changes: 10 additions & 5 deletions app/services/agent_service.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import asyncio
import json
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import cast

from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
from langgraph.graph.state import CompiledStateGraph
Expand All @@ -19,7 +20,10 @@


class AgentService:
def __init__(self, graph: CompiledStateGraph) -> None:
def __init__(
self,
graph: CompiledStateGraph[AgentState, None, AgentState, AgentState],
) -> None:
self._graph = graph

async def run(
Expand Down Expand Up @@ -64,7 +68,7 @@ async def run(
history.append(AIMessage(content=content))

# 3. Build initial state and invoke graph
run_id = f"run_{datetime.now(tz=timezone.utc).strftime('%Y%m%dT%H%M%S%f')}"
run_id = f"run_{datetime.now(tz=UTC).strftime('%Y%m%dT%H%M%S%f')}"
initial_state: AgentState = {
"messages": [*history, HumanMessage(content=message)],
"session_id": session_id,
Expand All @@ -76,8 +80,9 @@ async def run(
}

loop = asyncio.get_event_loop()
result: AgentState = await loop.run_in_executor(
None, self._graph.invoke, initial_state
result = cast(
AgentState,
await loop.run_in_executor(None, lambda: self._graph.invoke(initial_state)),
)

# 4. Extract final assistant response
Expand Down
1 change: 0 additions & 1 deletion app/services/api_key_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import secrets
import uuid
from dataclasses import dataclass
from datetime import datetime

import bcrypt

Expand Down
Loading
Loading