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 IDNameOrder IDStatusProduct
1Test User1pendingMechanical Keyboard
2Alice2processingWidget A
3Bob3shippedWidget 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 DESCLIMIT5;
request_idmethodpathstatus_codeduration_msclient_hostcreated_at
a1b2c3d4-...POST/api/v1/chat2002847.31172.17.0.12026-03-01 10:05:12+00
b2c3d4e5-...POST/api/v1/chat2001523.89172.17.0.12026-03-01 10:04:58+00
c3d4e5f6-...GET/health2001.23172.17.0.12026-03-01 10:04:30+00

Environment

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

VariableDescriptionDefault
DATABASE_URLRequired. Async Postgres URL (postgresql+asyncpg://...?sslmode=require)
ENVIRONMENTRuntime environment (development, staging, production)development
DEBUGEnable debug mode and verbose console loggingFalse
LLM_BASE_URLLLM API base URLhttp://host.docker.internal:11434
LLM_MODELTarget model nameqwen3:8b
LLM_API_KEYOptional API key for cloud LLM providers
LLM_TIMEOUTHTTP timeout for LLM calls (seconds)120.0
MAX_REACT_ITERATIONSMax ReAct loop iterations before aborting10
POOL_SIZESQLAlchemy connection pool size5
POOL_MAX_OVERFLOWMax connections above pool size10
POOL_RECYCLE_SECONDSRecycle DB connections after N seconds300
CORS_ALLOWED_ORIGINSJSON array of allowed CORS origins["*"]
LOG_LEVELRoot logging levelINFO
LOG_DIRDirectory for rotating log fileslogs
MAX_PROMPT_LENGTHMax character length for user prompts2000
BETTERSTACK_SOURCE_TOKENOptional 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

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

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 IDNameOrder IDStatusProduct
1Test User1pendingMechanical Keyboard
2Alice2processingWidget A
3Bob3shippedWidget 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 DESCLIMIT5;
request_idmethodpathstatus_codeduration_msclient_hostcreated_at
a1b2c3d4-...POST/api/v1/chat2002847.31172.17.0.12026-03-01 10:05:12+00
b2c3d4e5-...POST/api/v1/chat2001523.89172.17.0.12026-03-01 10:04:58+00
c3d4e5f6-...GET/health2001.23172.17.0.12026-03-01 10:04:30+00

Environment

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

VariableDescriptionDefault
DATABASE_URLRequired. Async Postgres URL (postgresql+asyncpg://...?sslmode=require)
ENVIRONMENTRuntime environment (development, staging, production)development
DEBUGEnable debug mode and verbose console loggingFalse
LLM_BASE_URLLLM API base URLhttp://host.docker.internal:11434
LLM_MODELTarget model nameqwen3:8b
LLM_API_KEYOptional API key for cloud LLM providers
LLM_TIMEOUTHTTP timeout for LLM calls (seconds)120.0
MAX_REACT_ITERATIONSMax ReAct loop iterations before aborting10
POOL_SIZESQLAlchemy connection pool size5
POOL_MAX_OVERFLOWMax connections above pool size10
POOL_RECYCLE_SECONDSRecycle DB connections after N seconds300
CORS_ALLOWED_ORIGINSJSON array of allowed CORS origins["*"]
LOG_LEVELRoot logging levelINFO
LOG_DIRDirectory for rotating log fileslogs
MAX_PROMPT_LENGTHMax character length for user prompts2000
BETTERSTACK_SOURCE_TOKENOptional 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

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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 IDNameOrder IDStatusProduct
1Test User1pendingMechanical Keyboard
2Alice2processingWidget A
3Bob3shippedWidget 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 DESCLIMIT5;
request_idmethodpathstatus_codeduration_msclient_hostcreated_at
a1b2c3d4-...POST/api/v1/chat2002847.31172.17.0.12026-03-01 10:05:12+00
b2c3d4e5-...POST/api/v1/chat2001523.89172.17.0.12026-03-01 10:04:58+00
c3d4e5f6-...GET/health2001.23172.17.0.12026-03-01 10:04:30+00

Environment

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

VariableDescriptionDefault
DATABASE_URLRequired. Async Postgres URL (postgresql+asyncpg://...?sslmode=require)
ENVIRONMENTRuntime environment (development, staging, production)development
DEBUGEnable debug mode and verbose console loggingFalse
LLM_BASE_URLLLM API base URLhttp://host.docker.internal:11434
LLM_MODELTarget model nameqwen3:8b
LLM_API_KEYOptional API key for cloud LLM providers
LLM_TIMEOUTHTTP timeout for LLM calls (seconds)120.0
MAX_REACT_ITERATIONSMax ReAct loop iterations before aborting10
POOL_SIZESQLAlchemy connection pool size5
POOL_MAX_OVERFLOWMax connections above pool size10
POOL_RECYCLE_SECONDSRecycle DB connections after N seconds300
CORS_ALLOWED_ORIGINSJSON array of allowed CORS origins["*"]
LOG_LEVELRoot logging levelINFO
LOG_DIRDirectory for rotating log fileslogs
MAX_PROMPT_LENGTHMax character length for user prompts2000
BETTERSTACK_SOURCE_TOKENOptional 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

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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 IDNameOrder IDStatusProduct
1Test User1pendingMechanical Keyboard
2Alice2processingWidget A
3Bob3shippedWidget 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 DESCLIMIT5;
request_idmethodpathstatus_codeduration_msclient_hostcreated_at
a1b2c3d4-...POST/api/v1/chat2002847.31172.17.0.12026-03-01 10:05:12+00
b2c3d4e5-...POST/api/v1/chat2001523.89172.17.0.12026-03-01 10:04:58+00
c3d4e5f6-...GET/health2001.23172.17.0.12026-03-01 10:04:30+00

Environment

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

VariableDescriptionDefault
DATABASE_URLRequired. Async Postgres URL (postgresql+asyncpg://...?sslmode=require)
ENVIRONMENTRuntime environment (development, staging, production)development
DEBUGEnable debug mode and verbose console loggingFalse
LLM_BASE_URLLLM API base URLhttp://host.docker.internal:11434
LLM_MODELTarget model nameqwen3:8b
LLM_API_KEYOptional API key for cloud LLM providers
LLM_TIMEOUTHTTP timeout for LLM calls (seconds)120.0
MAX_REACT_ITERATIONSMax ReAct loop iterations before aborting10
POOL_SIZESQLAlchemy connection pool size5
POOL_MAX_OVERFLOWMax connections above pool size10
POOL_RECYCLE_SECONDSRecycle DB connections after N seconds300
CORS_ALLOWED_ORIGINSJSON array of allowed CORS origins["*"]
LOG_LEVELRoot logging levelINFO
LOG_DIRDirectory for rotating log fileslogs
MAX_PROMPT_LENGTHMax character length for user prompts2000
BETTERSTACK_SOURCE_TOKENOptional 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

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

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 IDNameOrder IDStatusProduct
1Test User1pendingMechanical Keyboard
2Alice2processingWidget A
3Bob3shippedWidget 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 DESCLIMIT5;
request_idmethodpathstatus_codeduration_msclient_hostcreated_at
a1b2c3d4-...POST/api/v1/chat2002847.31172.17.0.12026-03-01 10:05:12+00
b2c3d4e5-...POST/api/v1/chat2001523.89172.17.0.12026-03-01 10:04:58+00
c3d4e5f6-...GET/health2001.23172.17.0.12026-03-01 10:04:30+00

Environment

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

VariableDescriptionDefault
DATABASE_URLRequired. Async Postgres URL (postgresql+asyncpg://...?sslmode=require)
ENVIRONMENTRuntime environment (development, staging, production)development
DEBUGEnable debug mode and verbose console loggingFalse
LLM_BASE_URLLLM API base URLhttp://host.docker.internal:11434
LLM_MODELTarget model nameqwen3:8b
LLM_API_KEYOptional API key for cloud LLM providers
LLM_TIMEOUTHTTP timeout for LLM calls (seconds)120.0
MAX_REACT_ITERATIONSMax ReAct loop iterations before aborting10
POOL_SIZESQLAlchemy connection pool size5
POOL_MAX_OVERFLOWMax connections above pool size10
POOL_RECYCLE_SECONDSRecycle DB connections after N seconds300
CORS_ALLOWED_ORIGINSJSON array of allowed CORS origins["*"]
LOG_LEVELRoot logging levelINFO
LOG_DIRDirectory for rotating log fileslogs
MAX_PROMPT_LENGTHMax character length for user prompts2000
BETTERSTACK_SOURCE_TOKENOptional 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

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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 IDNameOrder IDStatusProduct
1Test User1pendingMechanical Keyboard
2Alice2processingWidget A
3Bob3shippedWidget 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 DESCLIMIT5;
request_idmethodpathstatus_codeduration_msclient_hostcreated_at
a1b2c3d4-...POST/api/v1/chat2002847.31172.17.0.12026-03-01 10:05:12+00
b2c3d4e5-...POST/api/v1/chat2001523.89172.17.0.12026-03-01 10:04:58+00
c3d4e5f6-...GET/health2001.23172.17.0.12026-03-01 10:04:30+00

Environment

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

VariableDescriptionDefault
DATABASE_URLRequired. Async Postgres URL (postgresql+asyncpg://...?sslmode=require)
ENVIRONMENTRuntime environment (development, staging, production)development
DEBUGEnable debug mode and verbose console loggingFalse
LLM_BASE_URLLLM API base URLhttp://host.docker.internal:11434
LLM_MODELTarget model nameqwen3:8b
LLM_API_KEYOptional API key for cloud LLM providers
LLM_TIMEOUTHTTP timeout for LLM calls (seconds)120.0
MAX_REACT_ITERATIONSMax ReAct loop iterations before aborting10
POOL_SIZESQLAlchemy connection pool size5
POOL_MAX_OVERFLOWMax connections above pool size10
POOL_RECYCLE_SECONDSRecycle DB connections after N seconds300
CORS_ALLOWED_ORIGINSJSON array of allowed CORS origins["*"]
LOG_LEVELRoot logging levelINFO
LOG_DIRDirectory for rotating log fileslogs
MAX_PROMPT_LENGTHMax character length for user prompts2000
BETTERSTACK_SOURCE_TOKENOptional 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

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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 IDNameOrder IDStatusProduct
1Test User1pendingMechanical Keyboard
2Alice2processingWidget A
3Bob3shippedWidget 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 DESCLIMIT5;
request_idmethodpathstatus_codeduration_msclient_hostcreated_at
a1b2c3d4-...POST/api/v1/chat2002847.31172.17.0.12026-03-01 10:05:12+00
b2c3d4e5-...POST/api/v1/chat2001523.89172.17.0.12026-03-01 10:04:58+00
c3d4e5f6-...GET/health2001.23172.17.0.12026-03-01 10:04:30+00

Environment

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

VariableDescriptionDefault
DATABASE_URLRequired. Async Postgres URL (postgresql+asyncpg://...?sslmode=require)
ENVIRONMENTRuntime environment (development, staging, production)development
DEBUGEnable debug mode and verbose console loggingFalse
LLM_BASE_URLLLM API base URLhttp://host.docker.internal:11434
LLM_MODELTarget model nameqwen3:8b
LLM_API_KEYOptional API key for cloud LLM providers
LLM_TIMEOUTHTTP timeout for LLM calls (seconds)120.0
MAX_REACT_ITERATIONSMax ReAct loop iterations before aborting10
POOL_SIZESQLAlchemy connection pool size5
POOL_MAX_OVERFLOWMax connections above pool size10
POOL_RECYCLE_SECONDSRecycle DB connections after N seconds300
CORS_ALLOWED_ORIGINSJSON array of allowed CORS origins["*"]
LOG_LEVELRoot logging levelINFO
LOG_DIRDirectory for rotating log fileslogs
MAX_PROMPT_LENGTHMax character length for user prompts2000
BETTERSTACK_SOURCE_TOKENOptional 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

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

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 IDNameOrder IDStatusProduct
1Test User1pendingMechanical Keyboard
2Alice2processingWidget A
3Bob3shippedWidget 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 DESCLIMIT5;
request_idmethodpathstatus_codeduration_msclient_hostcreated_at
a1b2c3d4-...POST/api/v1/chat2002847.31172.17.0.12026-03-01 10:05:12+00
b2c3d4e5-...POST/api/v1/chat2001523.89172.17.0.12026-03-01 10:04:58+00
c3d4e5f6-...GET/health2001.23172.17.0.12026-03-01 10:04:30+00

Environment

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

VariableDescriptionDefault
DATABASE_URLRequired. Async Postgres URL (postgresql+asyncpg://...?sslmode=require)
ENVIRONMENTRuntime environment (development, staging, production)development
DEBUGEnable debug mode and verbose console loggingFalse
LLM_BASE_URLLLM API base URLhttp://host.docker.internal:11434
LLM_MODELTarget model nameqwen3:8b
LLM_API_KEYOptional API key for cloud LLM providers
LLM_TIMEOUTHTTP timeout for LLM calls (seconds)120.0
MAX_REACT_ITERATIONSMax ReAct loop iterations before aborting10
POOL_SIZESQLAlchemy connection pool size5
POOL_MAX_OVERFLOWMax connections above pool size10
POOL_RECYCLE_SECONDSRecycle DB connections after N seconds300
CORS_ALLOWED_ORIGINSJSON array of allowed CORS origins["*"]
LOG_LEVELRoot logging levelINFO
LOG_DIRDirectory for rotating log fileslogs
MAX_PROMPT_LENGTHMax character length for user prompts2000
BETTERSTACK_SOURCE_TOKENOptional 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

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages