Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
990e842
feat(ai): back the Agent with LangChain/LangGraph, keep the public AP…
bedus-creation Jun 23, 2026
478e72e
refactor(ai): config package + Lab provider/model resolution; wire Ag…
bedus-creation Jun 23, 2026
2dd8e40
wip(ai): drop create_agent — init_chat_model + hand-rolled Runner too…
bedus-creation Jun 23, 2026
dbfc847
feat(ai): container-bound fake() and record() agent testing harness
bedus-creation Jun 23, 2026
ae5aff6
test(agents): use unittest assertions in example feature tests
bedus-creation Jun 24, 2026
229ce43
Merge pull request #141 from fastapi-startkit/task/agents-unittest-as…
bedus-creation Jun 24, 2026
75a8b1e
refactor(ai): single fake() classmethod, Runner returns tool output, …
bedus-creation Jun 25, 2026
6522fd3
test(ai): convert ai test suite to unittest TestCase classes
bedus-creation Jun 25, 2026
2f91623
chore: ignore node_modules
bedus-creation Jun 25, 2026
a08a4bf
style(ai): drop explanatory comments from source
bedus-creation Jun 25, 2026
99f0b0f
refactor(ai): merge AIConfig via config store, drop unused _memory_ba…
bedus-creation Jun 25, 2026
7d4254c
chore(ai): default gemini model to gemini-2.5-flash-lite
bedus-creation Jun 25, 2026
c512f50
refactor(ai): make Agent.prompt() and stream() async
bedus-creation Jun 25, 2026
b4b5242
chore(ai): slim the [ai] extra to langchain + langchain-core
bedus-creation Jun 25, 2026
221d930
fix(ai): stream through middleware, agent-driven Runner, bind tools i…
bedus-creation Jun 25, 2026
1610fc1
test(ai): cover middleware pipeline deferred-response after-hooks
bedus-creation Jun 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.idea
.DS_Store
**/.venv
**/node_modules/
**/__pycache__/
**/*.db
**/*.sqlite3
Expand Down
109 changes: 109 additions & 0 deletions example/agents/.ai/fastapi-startkit/fastapi/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
---
name: fastapi-startkit
description: Routing, controllers, ORM, requests, resources, and action pattern for fastapi-startkit applications.
---

# Fastapi's Routing

### Fastapi Startkit's Router
```python
# routes/web.py
from fastapi_startkit.fastapi import Router

router = Router()
```

and use the crud resources, for example
```python
router.post("/users", users_controller.store)
router.put("/users/{user_id}", users_controller.update)
router.patch("/users/{user_id}", users_controller.patch)
router.delete("/users", users_controller.destroy)
```

the controller will look like
```python
# app/http/controllers/users_controller.py
async def index(request: Request):
pass

async def show(user_id: int):
pass

async def store(data: UserSchema):
pass

async def update(user_id: int, data: UserSchema):
pass

async def destroy(user_id: int):
pass
```

or use the resource function as:
```python
router.resource("users", users_controller, excepts=['create', 'edit'])
```

## ORM
```python
# app/models/user.py
from fastapi_startkit.masoniteorm import Model

class User(Model):
id: int
name: str
email: str
metadata: dict
```

and use the orm as:
```python
# app/http/controllers/users_controller.py
from app.models import User

async def store(request: UserStoreRequest):
user = User.create(request.model_dump())
...
```

the `UserStoreRequest` will look like:
```python
# app/http/requests/user_store_request.py
from pydantic import BaseModel

class UserStoreRequest(BaseModel):
name: str
```

and use JsonApiResource to return JSON response from the controller:
```python
from fastapi_startkit.jsonapi import JsonResource

# app/http/controllers/users_controller.py
from app.models import User

async def store(request: UserStoreRequest):
user = User.create(request.model_dump())
return JsonResource(user)
```

## Architecture

use the action pattern to write complex logic.
```python
# app/actions/user_actions.py
from app.models import User

class UserStoreAction:
def __init__(self, request: UserStoreRequest):
self.request = request

@staticmethod
def prepare(request: UserStoreRequest) -> 'UserStoreAction':
return UserStoreAction(request)

def handle(self) -> JsonResource[User]:
user = User.create(self.request.model_dump())
return JsonResource(user)
```
5 changes: 5 additions & 0 deletions example/agents/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
APP_ENV=local
APP_URL=http://127.0.0.1:7654

AI_PROVIDER=google
GEMINI_API_KEY=
31 changes: 31 additions & 0 deletions example/agents/.github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Lint

on:
push:
branches: ["**"]
pull_request:
branches: ["**"]

jobs:
ruff:
name: Ruff
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5

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

- name: Install dependencies
run: uv sync --group dev

- name: Run ruff check
run: uv run ruff check .

- name: Run ruff format check
run: uv run ruff format --check .
37 changes: 37 additions & 0 deletions example/agents/.github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Test

on:
pull_request:
branches: ["**"]

jobs:
tests:
name: Pytest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5

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

- name: Install dependencies
run: uv sync --group dev

- name: Run tests
shell: bash
run: |
set +e
uv run pytest tests/ -v
code=$?
# Exit code 5 means "no tests collected" — treat as success so the
# starter template's CI stays green until real tests are added.
if [ "$code" -eq 5 ]; then
echo "No tests collected — treating as success."
exit 0
fi
exit $code
6 changes: 6 additions & 0 deletions example/agents/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.idea
.DS_Store
.venv
**__pycache__
storage
.env
17 changes: 17 additions & 0 deletions example/agents/app/agents/chat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from typing import Callable

from fastapi_startkit.ai import Agent, Middleware

from app.middleware.agent_logger import AgentLogger
from app.tools.job_search_tool import job_search_tool


class RouterAgent(Agent):
def middleware(self) -> list[Middleware]:
return [AgentLogger()]

def tools(self) -> list[Callable]:
return [job_search_tool]

def instructions(self) -> str:
return "You are a friendly customer support assistant."
25 changes: 25 additions & 0 deletions example/agents/app/middleware/agent_logger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import time
from collections.abc import Callable
from typing import Any

from langchain_core.language_models.chat_models import BaseChatModel

from fastapi_startkit.logging import Logger


def _model_name(model: BaseChatModel) -> str:
return getattr(model, "model", None) or getattr(model, "model_name", None) or type(model).__name__


class AgentLogger:
def handle(self, model: BaseChatModel, handler: Callable) -> Any:
Logger.info(f"request | model={_model_name(model)}")
started_at = time.monotonic()

def log_response(final: Any) -> None:
elapsed = time.monotonic() - started_at
meta = getattr(final, "usage_metadata", None) or {}
preview = str(getattr(final, "content", final) or "")[:200].replace("\n", " ")
Logger.info(f"response | {elapsed:.2f}s | in={meta.get('input_tokens', '?')} out={meta.get('output_tokens', '?')} tokens | {preview}")

return handler(model).then(log_response)
13 changes: 13 additions & 0 deletions example/agents/app/providers/fastapi_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from fastapi_startkit.fastapi import FastAPIProvider as BaseFastAPIProvider
from fastapi.templating import Jinja2Templates


class FastapiProvider(BaseFastAPIProvider):
def boot(self):
templates_dir = self.app.use_base_path("resources/templates")
self.app.bind("templates", Jinja2Templates(directory=str(templates_dir)))

super().boot()
from routes.api import api

self.app.fastapi.include_router(api)
5 changes: 5 additions & 0 deletions example/agents/app/requests/chat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from pydantic import BaseModel, Field


class ChatRequest(BaseModel):
message: str = Field(...)
8 changes: 8 additions & 0 deletions example/agents/app/schema/route.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from enum import StrEnum

from pydantic import BaseModel


class Route(StrEnum):
JOB_SEARCH: str
CHAT: str
25 changes: 25 additions & 0 deletions example/agents/app/tools/job_search_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from langchain_core.tools import tool

jobs = [
{"id": 1, "title": "Software Engineer", "location": "San Francisco", "company": "Acme Corp", "type": "Full-time"},
{"id": 2, "title": "Frontend Developer", "location": "Remote", "company": "Startup Inc", "type": "Full-time"},
{"id": 3, "title": "Data Scientist", "location": "New York", "company": "DataCo", "type": "Full-time"},
{"id": 4, "title": "DevOps Engineer", "location": "Austin", "company": "CloudBase", "type": "Contract"},
{"id": 5, "title": "Product Manager", "location": "Remote", "company": "ProductHQ", "type": "Full-time"},
]


@tool
def job_search_tool(query: str) -> list:
"""Searches for jobs based on the given query. Supports wildcards (* and ?) in each term."""
import fnmatch

patterns = [f"*{term}*" for term in query.lower().split()]

return [
job for job in jobs
if any(
fnmatch.fnmatch(" ".join(str(v) for v in job.values()).lower(), pattern)
for pattern in patterns
)
]
9 changes: 9 additions & 0 deletions example/agents/artisan
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/usr/bin/env python3

import sys

from bootstrap.application import app

if __name__ == "__main__":
status = app.handle_command()
sys.exit(status if isinstance(status, int) else 0)
25 changes: 25 additions & 0 deletions example/agents/bootstrap/application.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from pathlib import Path

from fastapi_startkit import Application
from fastapi_startkit.inertia import InertiaProvider
from fastapi_startkit.logging import LogProvider
from fastapi_startkit.skills import AISkillProvider
from fastapi_startkit.vite import ViteProvider
from fastapi_startkit.ai import AIProvider

from config.fastapi import FastAPIConfig
from config.logging import LoggingConfig
from config.vite import ViteConfig
from app.providers.fastapi_provider import FastapiProvider

app: Application = Application(
base_path=Path(__file__).resolve().parent.parent,
providers=[
AISkillProvider,
(LogProvider,LoggingConfig),
(FastapiProvider, FastAPIConfig),
AIProvider,
(ViteProvider, ViteConfig),
InertiaProvider,
],
)
18 changes: 18 additions & 0 deletions example/agents/config/fastapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import dataclasses

from fastapi_startkit.environment import env


@dataclasses.dataclass
class FastAPIConfig:
app_url: str = dataclasses.field(default_factory=lambda: env("APP_URL", "http://127.0.0.1:8000"))
reload: bool = dataclasses.field(default_factory=lambda: env("APP_RELOAD", True))
reload_dirs: list | None = None
reload_excludes: list = dataclasses.field(
default_factory=lambda: [
"*.log",
"tests/*",
"node_modules/*",
"storage/*",
]
)
22 changes: 22 additions & 0 deletions example/agents/config/logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import dataclasses

from fastapi_startkit.environment import env
from fastapi_startkit.logging.config import StackChannel, DailyChannel, TerminalChannel


@dataclasses.dataclass
class LoggingConfig:
default: str = dataclasses.field(default_factory=lambda: env("LOG_CHANNEL", "stack"))

channels: dict = dataclasses.field(
default_factory=lambda: {
"stack": StackChannel(driver="stack", channels=["daily", "terminal"]),
"daily": DailyChannel(
level=env("LOG_DAILY_LEVEL", "debug"),
path=env("LOG_DAILY_PATH", "storage/logs"),
),
"terminal": TerminalChannel(
level=env("LOG_TERMINAL_LEVEL", "info"),
),
}
)
12 changes: 12 additions & 0 deletions example/agents/config/vite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from pydantic.dataclasses import dataclass


@dataclass
class ViteConfig:
public_path: str = "public"
build_directory: str = "build"
hot_file: str = "hot"
manifest_filename: str = "manifest.json"
asset_url: str = ""
static_url: str = "/build"
mount_static: bool = True
Loading
Loading