Skip to content

samshad/autonomous-agent-api

Repository files navigation

Autonomous Agent API

Async FastAPI API that acts as an autonomous customer support agent: natural language → ReAct loop + tool use (order lookup, cancel, list) → PostgreSQL via SQLAlchemy → natural language response.

System Architecture

System Architecture

Features

  • Natural language to action: e.g. "Cancel my order #12345" → validated DB update
  • ReAct orchestration: Reason → Act (tools) → Observe → repeat
  • Strict schema validation: Pydantic + JSON Schema for LLM tool binding
  • 100% async: FastAPI, SQLAlchemy async ORM, asyncpg, httpx for LLM
  • DDD layout: api/agent/services/repository/
  • Neon Serverless Postgres: SSL and pool settings tuned for Neon (scale-to-zero safe)
  • Request tracing: Every request gets a X-Request-ID correlation header, persisted to request_logs table in Postgres
  • Alembic migrations: Schema changes are version-controlled and applied via alembic upgrade head

Database: Neon Serverless Postgres

The app is configured for Neon serverless Postgres:

  1. Create a project at Neon Console and copy the connection string.
  2. In the Connect dialog, use the connection string and change the scheme to postgresql+asyncpg://.
  3. Ensure the URL includes ?ssl=require (Neon requires SSL).
  4. Optional: use the pooler endpoint for serverless/scale-to-zero.
  5. Set DATABASE_URL in .env. Optionally set POOL_RECYCLE_SECONDS (e.g. 300) to avoid stale connections after suspend.

Quick start with Docker

# Build and run API
docker compose up -d

# Apply migrations (run once, or after pulling new migrations)
uv run alembic upgrade head

# Seed DB with demo data (run once)
uv run python seed_db.py

Then call the API:

curl -X POST http://localhost:8000/api/v1/chat \
  -H "Content-Type: application/json" \
  -d '{"prompt": "What is the status of order 1?", "user_id": 1}'

Health check:

curl http://localhost:8000/health

Local development

  • Python 3.12+, uv
  • Create .env from .env.example, set DATABASE_URL and optionally LLM_BASE_URL.
uv sync
uv run alembic upgrade head
uv run python seed_db.py
uv run uvicorn agent_api.main:app --reload --port 8000

Database migrations (Alembic)

# Apply all pending migrations
uv run alembic upgrade head

# Create a new migration after changing models in domain.py
uv run alembic revision --autogenerate -m "describe your change"

# Rollback one migration
uv run alembic downgrade -1

# View current migration state
uv run alembic current

Project layout

src/agent_api/
├── main.py           # FastAPI app factory, lifespan, health check
├── api/              # Presentation: routes, dependencies
├── core/              # Config, DB, logging, exceptions, middleware
├── models/            # Domain ORM, HTTP schemas, agent payloads
├── repository/        # Data access (order_repo)
├── services/          # Business logic (commerce)
└── agent/             # ReAct engine, LLM client, tool registry, tools
migrations/            # Alembic migration scripts (version-controlled)

Tests

# Unit + integration tests (no real DB required)
uv run pytest tests/unit/ tests/integration/test_database.py tests/integration/test_domain_models.py tests/integration/test_order_repo.py -v

# Full suite including real-DB tests (requires DATABASE_URL in .env)
uv run pytest -v

# With coverage
uv run pytest --cov=agent_api --cov-report=term-missing

🧪 Full Example Interaction (Copy-Paste for Postman)

The following shows a realistic end-to-end session with the AI agent. After running docker compose up -d and uv run python seed_db.py, the database contains:

User ID Name Order ID Status Product
1 Test User 1 pending Mechanical Keyboard
2 Alice 2 processing Widget A
3 Bob 3 shipped Widget B

You can copy-paste every curl command below directly into your terminal or import them into Postman.

1️⃣ Health Check

GET http://localhost:8000/health

Response:

{
  "status": "ok",
  "version": "0.1.0"
}

2️⃣ Ask the agent to list all orders for a user

POST http://localhost:8000/api/v1/chat
Content-Type: application/json

{
  "prompt": "Show me all my orders",
  "user_id": 1
}

Response:

{
  "response": "Here are your orders:\n\n- **Order #1** | Status: pending | Date: 2026-03-01T04:02:00+00:00\n\nYou currently have 1 order on file. Would you like to do anything with it?",
  "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}

What happened behind the scenes:

  1. The LLM read the user's prompt: "Show me all my orders"
  2. It decided to call the list_orders_tool with user_id=1
  3. The tool queried Postgres → found 1 order
  4. The LLM read the tool's output and composed a natural-language summary

3️⃣ Ask the agent for details about a specific order

POST http://localhost:8000/api/v1/chat
Content-Type: application/json

{
  "prompt": "What is the status of order 1?",
  "user_id": 1
}

Response:

{
  "response": "Order #1 is currently **pending**. It was placed on 2026-03-01. Would you like to cancel it or do anything else?",
  "request_id": "b2c3d4e5-f6a7-8901-bcde-f12345678901"
}

4️⃣ Cancel an order (success — order is pending)

POST http://localhost:8000/api/v1/chat
Content-Type: application/json

{
  "prompt": "Yes, please cancel order 1",
  "user_id": 1
}

Response:

{
  "response": "Done! Order #1 has been successfully cancelled. The status is now 'cancelled'. Is there anything else I can help you with?",
  "request_id": "c3d4e5f6-a7b8-9012-cdef-123456789012"
}

What happened:

  1. LLM called cancel_order_tool with order_id=1, user_id=1
  2. CommerceService verified ownership (user 1 owns order 1) ✓
  3. Business rule check: order was "pending" → cancellable ✓
  4. Status updated to "cancelled" in Postgres via OrderRepository
  5. LLM received the success confirmation and composed the reply

5️⃣ Try to cancel again (idempotent — already cancelled)

POST http://localhost:8000/api/v1/chat
Content-Type: application/json

{
  "prompt": "Cancel order 1",
  "user_id": 1
}

Response:

{
  "response": "I wasn't able to cancel Order #1 because it's already been cancelled. No further action is needed. Is there anything else I can help with?",
  "request_id": "d4e5f6a7-b8c9-0123-defa-234567890123"
}

6️⃣ Try to cancel a shipped order (business rule rejection)

POST http://localhost:8000/api/v1/chat
Content-Type: application/json

{
  "prompt": "Cancel order 3",
  "user_id": 3
}

Response:

{
  "response": "I'm sorry, but I can't cancel Order #3 because it has already been shipped. For shipped orders, please contact our support team to arrange a return.",
  "request_id": "e5f6a7b8-c9d0-1234-efab-345678901234"
}

7️⃣ Try to access another user's order (ownership guard)

POST http://localhost:8000/api/v1/chat
Content-Type: application/json

{
  "prompt": "What's the status of order 2?",
  "user_id": 1
}

Response:

{
  "response": "I wasn't able to find Order #2 in your account. Please double-check the order number and try again.",
  "request_id": "f6a7b8c9-d0e1-2345-fabc-456789012345"
}

⚠️ Security note: The API deliberately returns "not found" instead of "access denied" to avoid leaking the existence of other users' orders.


8️⃣ Vague prompt — agent asks for clarification

POST http://localhost:8000/api/v1/chat
Content-Type: application/json

{
  "prompt": "I want to cancel my order",
  "user_id": 2
}

Response:

{
  "response": "I'd be happy to help you cancel an order! Could you please provide me with the order number you'd like to cancel?",
  "request_id": "a7b8c9d0-e1f2-3456-abcd-567890123456"
}

The LLM doesn't guess — it follows the system prompt instruction to ask for the order ID.


9️⃣ Verify the X-Request-ID response header

Every response includes the X-Request-ID header for distributed tracing:

curl -i -X POST http://localhost:8000/api/v1/chat \
  -H "Content-Type: application/json" \
  -d '{"prompt": "Show my orders", "user_id": 1}'

Response headers:

HTTP/1.1 200 OK
content-type: application/json
x-request-id: a1b2c3d4-e5f6-7890-abcd-ef1234567890

You can also send your own request ID for tracing across services:

curl -X POST http://localhost:8000/api/v1/chat \
  -H "Content-Type: application/json" \
  -H "X-Request-ID: my-custom-trace-id-123" \
  -d '{"prompt": "Show my orders", "user_id": 1}'

The API will use your provided ID instead of generating one, and it will appear in:

  • The response X-Request-ID header
  • The response JSON body (request_id field)
  • Every structured log line for that request
  • The request_logs table in PostgreSQL

🔍 Query the request_logs table (Observability)

Every API request is persisted to the request_logs table in PostgreSQL:

SELECT request_id, method, path, status_code, duration_ms, client_host, created_at
FROM request_logs
ORDER BY created_at DESC
LIMIT 5;
request_id method path status_code duration_ms client_host created_at
a1b2c3d4-... POST /api/v1/chat 200 2847.31 172.17.0.1 2026-03-01 10:05:12+00
b2c3d4e5-... POST /api/v1/chat 200 1523.89 172.17.0.1 2026-03-01 10:04:58+00
c3d4e5f6-... GET /health 200 1.23 172.17.0.1 2026-03-01 10:04:30+00

Environment

All configuration is driven by environment variables (via .env). See .env.example for the full list.

Variable Description Default
DATABASE_URL Required. Async Postgres URL (postgresql+asyncpg://...?sslmode=require)
ENVIRONMENT Runtime environment (development, staging, production) development
DEBUG Enable debug mode and verbose console logging False
LLM_BASE_URL LLM API base URL http://host.docker.internal:11434
LLM_MODEL Target model name qwen3:8b
LLM_API_KEY Optional API key for cloud LLM providers
LLM_TIMEOUT HTTP timeout for LLM calls (seconds) 120.0
MAX_REACT_ITERATIONS Max ReAct loop iterations before aborting 10
POOL_SIZE SQLAlchemy connection pool size 5
POOL_MAX_OVERFLOW Max connections above pool size 10
POOL_RECYCLE_SECONDS Recycle DB connections after N seconds 300
CORS_ALLOWED_ORIGINS JSON array of allowed CORS origins ["*"]
LOG_LEVEL Root logging level INFO
LOG_DIR Directory for rotating log files logs
MAX_PROMPT_LENGTH Max character length for user prompts 2000
BETTERSTACK_SOURCE_TOKEN Optional Better Stack observability token

License

This project is licensed under the MIT License.

About

An asynchronous, autonomous AI support agent built with FastAPI, ReAct orchestration, and SQLAlchemy. Converts natural language directly into secure, validated PostgreSQL database transactions.

Topics

Resources

License

Stars

Watchers

Forks

Contributors

Languages