diff --git a/example/ai-agent-app/CLAUDE.md b/example/ai-agent-app/CLAUDE.md new file mode 100644 index 00000000..05a67e0b --- /dev/null +++ b/example/ai-agent-app/CLAUDE.md @@ -0,0 +1,122 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Start full dev server (Vite HMR + FastAPI backend together) +npm run dev + +# Backend only +uv run python artisan serve + +# Frontend only +npx vite dev + +# Build frontend assets +npm run build + +# Run tests +uv run pytest + +# Run a single test file +uv run pytest tests/path/to/test_file.py -v +``` + +Python dependencies are managed with `uv`. After adding a new package to `pyproject.toml`, run `uv sync` from this directory. + +## Architecture + +This app is a FastAPI Startkit example demonstrating an LLM chat interface with Inertia.js + React on the frontend. It is a **uv workspace member** of the parent monorepo; `fastapi-startkit` is installed as an editable path dependency from `../../fastapi_startkit`. + +### Request lifecycle + +``` +Browser → Inertia.js → FastAPI → Router → Controller → Agent → LangChain provider → LLM +``` + +- `routes/web.py` — defines all HTTP routes using `Router` (a thin wrapper over FastAPI's `APIRouter`). +- `app/controllers/` — handle HTTP requests; `chat_controller.store` returns a `StreamingResponse` backed by `agent.stream()`. +- `app/agents/` — subclasses of `Agent` from `packages/agent.py`. +- `app/requests/` — Pydantic models for request body validation. + +### Application boot sequence + +`bootstrap/application.py` instantiates `Application` with an ordered list of service providers: + +``` +LogProvider → AIProvider → FastAPIProvider → ViteProvider → InertiaProvider +``` + +Each provider has a `register()` phase (bind into container) and a `boot()` phase (safe to resolve dependencies). `FastAPIProvider.boot()` is where routes are mounted and Inertia is configured. + +### Agent framework (`packages/agent.py`) + +The core of the app. Agents are declared with class decorators and override lifecycle methods: + +```python +@provider("anthropic") # "anthropic" | "openai" | "google" +@model("claude-sonnet-4-6") +@max_tokens(2048) +class MyAgent(Agent): + def messages(self): # system prompt + few-shot history + return [{"role": "system", "content": "..."}] + + def tools(self): # list of callables — auto-wrapped as LangChain StructuredTools + return [my_tool_fn] + + def schema(self): # optional Pydantic model for structured output + return MyOutputModel + + def before(self, message): # pre-processing hook + return message + + def after(self, response): # post-processing hook + return response + + def provider_options(self): # provider-specific kwargs (e.g. extended thinking) + return {"anthropic": {"thinking": {"type": "enabled", "budget_tokens": 1024}}} +``` + +- `agent.prompt(message)` — full agentic loop (supports tool calls, up to `_max_steps` iterations). +- `agent.stream(message)` — token-by-token streaming iterator; **tool calls are not supported** during streaming. +- `agent.fake(patterns)` / `agent.assert_prompted()` — test helpers for offline replay and call assertions. + +Provider credentials are resolved from the framework's `Config` facade first, falling back to direct environment variables. API keys live in `.env` and are loaded by `AIConfig` in `packages/config.py` via `AIProvider`. + +### Memory system (`memory.py` / `memory.md`) + +A production-grade memory system is being built for this app. The architecture (documented in `memory.md`) has seven layers: + +| Layer | Responsibility | +|---|---| +| **Memory Router** | Classify input and route to the right store | +| **Semantic Memory** | Facts, preferences, long-term knowledge (PostgreSQL + pgvector) | +| **Episodic Memory** | Append-only event/conversation log | +| **Task Memory** | Goals, todos, agent progress | +| **Retrieval Layer** | Hybrid search with similarity + recency + importance scoring | +| **Working Memory** | Active context injected into the LLM prompt | +| **Reflection Engine** | Summarises episodes into semantic memories | + +The fluent API is: + +```python +memory.capture(...) +memory.search(...) +memory.semantic.add(...) +memory.tasks.create(...) +memory.reflect() +``` + +### Frontend + +React + Inertia.js rendered inside `templates/index.html` (Jinja2). Pages live in `resources/js/Pages/` and are resolved by the Inertia adapter. The active page for the chat is `resources/js/Pages/Chat/Index.tsx`. + +Vite serves assets in dev (HMR) and writes hashed bundles to `public/` on build. The `fastapi-vite-plugin` handles the manifest integration between Vite and the Python backend. Path alias `@/` maps to `resources/js/`. + +### AI provider config + +`packages/config.py` defines `AIConfig`, `AnthropicConfig`, `OpenAIConfig`, and `GoogleConfig` as dataclasses using `env()` for environment variable resolution. `AIProvider` registers `AIConfig` into the container under the `"ai"` key. Inside agents, `_provider_config()` resolves credentials via `Config.get("ai").providers[provider_name]`. + +Switch the active provider by setting `AI_PROVIDER=anthropic|openai|google` in `.env` and updating the `@provider` / `@model` decorators on the agent class. diff --git a/example/ai-agent-app/app/agents/chat.py b/example/ai-agent-app/app/agents/chat.py new file mode 100644 index 00000000..d926ede2 --- /dev/null +++ b/example/ai-agent-app/app/agents/chat.py @@ -0,0 +1,8 @@ +from packages.agent import Agent, model, provider + + +@provider("google") +@model("gemini-2.5-flash-lite") +class ChatAgent(Agent): + def messages(self): + return [{"role": "system", "content": "You are a helpful assistant."}] diff --git a/example/ai-agent-app/app/controllers/chat_controller.py b/example/ai-agent-app/app/controllers/chat_controller.py new file mode 100644 index 00000000..118e88c8 --- /dev/null +++ b/example/ai-agent-app/app/controllers/chat_controller.py @@ -0,0 +1,15 @@ +from fastapi import Request +from fastapi.responses import StreamingResponse +from fastapi_startkit.inertia import Inertia + +from app.agents.chat import ChatAgent +from app.requests.chat import ChatRequest + + +async def index(request: Request): + return Inertia.render("Chat/Index", {}) + + +async def store(body: ChatRequest) -> StreamingResponse: + agent = ChatAgent() + return StreamingResponse(agent.stream(body.message), media_type="text/plain") diff --git a/example/ai-agent-app/app/requests/chat.py b/example/ai-agent-app/app/requests/chat.py new file mode 100644 index 00000000..1e69cdeb --- /dev/null +++ b/example/ai-agent-app/app/requests/chat.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class ChatRequest(BaseModel): + message: str diff --git a/example/ai-agent-app/config/__init__.py b/example/ai-agent-app/config/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/example/ai-agent-app/packages/agent.py b/example/ai-agent-app/packages/agent.py index af6b013c..d54a5b29 100644 --- a/example/ai-agent-app/packages/agent.py +++ b/example/ai-agent-app/packages/agent.py @@ -43,57 +43,71 @@ def provider_options(self): def provider(name: str): """Set the LLM provider: 'anthropic', 'openai', 'google', etc.""" + def decorator(cls): cls._provider = name return cls + return decorator def model(name: str = ""): """Set the model identifier (e.g. 'claude-sonnet-4-6', 'gpt-4o').""" + def decorator(cls): cls._model = name return cls + return decorator def max_steps(n: int = 10): """Maximum agentic loop iterations before stopping.""" + def decorator(cls): cls._max_steps = n return cls + return decorator def max_tokens(n: int = 4096): """Maximum output tokens per response.""" + def decorator(cls): cls._max_tokens = n return cls + return decorator def timeout(seconds: float = 30.0): """Request timeout in seconds.""" + def decorator(cls): cls._timeout = seconds return cls + return decorator def top_p(value: float = 1.0): """Top-p nucleus sampling parameter.""" + def decorator(cls): cls._top_p = value return cls + return decorator def memory(backend: str = ""): """Attach a named memory backend to this agent.""" + def decorator(cls): cls._memory_backend = backend return cls + return decorator @@ -103,6 +117,7 @@ def decorator(cls): @dataclass class AgentResponse: """Returned by Agent.prompt(). Wraps the LLM response.""" + content: str = "" tool_calls: list[dict] = field(default_factory=list) usage: dict = field(default_factory=dict) @@ -140,6 +155,7 @@ class AgentSnapshot: agent.fake({"*analyze*": AgentSnapshot(path="tests/fixtures/analysis.json")}) """ + path: str def exists(self) -> bool: @@ -209,7 +225,11 @@ def to_anthropic_block(self) -> dict: """Return an Anthropic-compatible content block for this document.""" return { "type": "document", - "source": {"type": "text", "media_type": self.media_type, "data": self.content}, + "source": { + "type": "text", + "media_type": self.media_type, + "data": self.content, + }, "title": self.name, } @@ -420,7 +440,9 @@ def stream( """ message = self.before(message) self._log_call("stream", message) - yield from self._stream(message, system=system, model=model, provider_options=provider_options) + yield from self._stream( + message, system=system, model=model, provider_options=provider_options + ) def fake(self, patterns: dict[str, AgentResponse | AgentSnapshot]) -> "Agent": """ @@ -451,9 +473,13 @@ def assert_prompted(self, times: int | None = None) -> None: """ calls = [c for c in self._call_log if c["method"] in ("prompt", "stream")] if times is not None: - assert len(calls) == times, f"Expected {times} prompt call(s), got {len(calls)}" + assert len(calls) == times, ( + f"Expected {times} prompt call(s), got {len(calls)}" + ) else: - assert len(calls) > 0, "Expected at least one prompt() or stream() call, but none were made" + assert len(calls) > 0, ( + "Expected at least one prompt() or stream() call, but none were made" + ) def assert_not_prompted(self) -> None: """Assert that prompt() and stream() were never called.""" @@ -545,6 +571,7 @@ def _build_messages( def _tools_schema(self) -> list[dict]: """Convert tools() callables to Anthropic-style tool definitions.""" import inspect + result = [] for tool in self.tools(): if not callable(tool): @@ -552,6 +579,7 @@ def _tools_schema(self) -> list[dict]: sig = inspect.signature(tool) try: import typing + hints = typing.get_type_hints(tool) except Exception: hints = {} @@ -559,18 +587,21 @@ def _tools_schema(self) -> list[dict]: name: {"type": _python_type_to_json(hints.get(name, str))} for name in sig.parameters } - result.append({ - "name": tool.__name__, - "description": inspect.getdoc(tool) or tool.__name__, - "input_schema": { - "type": "object", - "properties": properties, - "required": [ - n for n, p in sig.parameters.items() - if p.default is inspect.Parameter.empty - ], - }, - }) + result.append( + { + "name": tool.__name__, + "description": inspect.getdoc(tool) or tool.__name__, + "input_schema": { + "type": "object", + "properties": properties, + "required": [ + n + for n, p in sig.parameters.items() + if p.default is inspect.Parameter.empty + ], + }, + } + ) return result # ── Provider dispatch ─────────────────────────────────────────────────── @@ -584,15 +615,21 @@ def _run( attachments: list[Document] | None, provider_options: dict | None, ) -> AgentResponse: - resolved_system, messages = self._build_messages(message, system, extra_messages, attachments) + resolved_system, messages = self._build_messages( + message, system, extra_messages, attachments + ) resolved_model = self._resolve_model(model) options = self._get_provider_options(provider_options) if self._provider == "anthropic": - return self._run_anthropic(resolved_system, messages, resolved_model, options) + return self._run_anthropic( + resolved_system, messages, resolved_model, options + ) if self._provider == "openai": return self._run_openai(resolved_system, messages, resolved_model, options) - raise ValueError(f"Unsupported provider: {self._provider!r}. Use 'anthropic' or 'openai'.") + raise ValueError( + f"Unsupported provider: {self._provider!r}. Use 'anthropic' or 'openai'." + ) def _stream( self, @@ -606,11 +643,17 @@ def _stream( options = self._get_provider_options(provider_options) if self._provider == "anthropic": - yield from self._stream_anthropic(resolved_system, messages, resolved_model, options) + yield from self._stream_anthropic( + resolved_system, messages, resolved_model, options + ) elif self._provider == "openai": - yield from self._stream_openai(resolved_system, messages, resolved_model, options) + yield from self._stream_openai( + resolved_system, messages, resolved_model, options + ) else: - raise ValueError(f"Unsupported provider: {self._provider!r}. Use 'anthropic' or 'openai'.") + raise ValueError( + f"Unsupported provider: {self._provider!r}. Use 'anthropic' or 'openai'." + ) # ── Anthropic ────────────────────────────────────────────────────────── @@ -651,11 +694,13 @@ def _run_anthropic( result = self._execute_tool(tu.name, tu.input) except Exception as exc: result = f"Error: {exc}" - tool_results.append({ - "type": "tool_result", - "tool_use_id": tu.id, - "content": str(result), - }) + tool_results.append( + { + "type": "tool_result", + "tool_use_id": tu.id, + "content": str(result), + } + ) # Feed results back into the conversation params["messages"] = [ @@ -664,17 +709,19 @@ def _run_anthropic( {"role": "user", "content": tool_results}, ] - content = "".join( - b.text for b in resp.content if hasattr(b, "text") - ) + content = "".join(b.text for b in resp.content if hasattr(b, "text")) tool_calls = [ {"name": b.name, "input": b.input} - for b in resp.content if b.type == "tool_use" + for b in resp.content + if b.type == "tool_use" ] return AgentResponse( content=content, tool_calls=tool_calls, - usage={"input": resp.usage.input_tokens, "output": resp.usage.output_tokens}, + usage={ + "input": resp.usage.input_tokens, + "output": resp.usage.output_tokens, + }, raw=resp, ) diff --git a/example/ai-agent-app/packages/ai_provider.py b/example/ai-agent-app/packages/ai_provider.py new file mode 100644 index 00000000..35358775 --- /dev/null +++ b/example/ai-agent-app/packages/ai_provider.py @@ -0,0 +1,9 @@ +from fastapi_startkit.providers import ServiceProvider + +from packages.config import AIConfig + + +class AIProvider(ServiceProvider): + def register(self) -> None: + config = self.app.make("config") + config.set("ai", AIConfig()) diff --git a/example/ai-agent-app/packages/config.py b/example/ai-agent-app/packages/config.py new file mode 100644 index 00000000..a6c2bc9f --- /dev/null +++ b/example/ai-agent-app/packages/config.py @@ -0,0 +1,42 @@ +from dataclasses import dataclass, field + +from fastapi_startkit.environment import env + + +@dataclass +class OpenAIConfig: + driver: str = "openai" + key: str = field(default_factory=lambda: env("OPENAI_API_KEY", "")) + url: str = field( + default_factory=lambda: env("OPENAI_BASE_URL", "https://api.openai.com/v1") + ) + + +@dataclass +class AnthropicConfig: + driver: str = "anthropic" + key: str = field(default_factory=lambda: env("ANTHROPIC_API_KEY", "")) + url: str = field( + default_factory=lambda: env("ANTHROPIC_BASE_URL", "https://api.anthropic.com") + ) + + +@dataclass +class GoogleConfig: + driver: str = "google" + key: str = field( + default_factory=lambda: env("GEMINI_API_KEY", "") or env("GOOGLE_API_KEY", "") + ) + + +@dataclass +class AIConfig: + default: str = field(default_factory=lambda: env("AI_PROVIDER", "google")) + + providers: dict = field( + default_factory=lambda: { + "openai": OpenAIConfig(), + "anthropic": AnthropicConfig(), + "google": GoogleConfig(), + } + ) diff --git a/example/ai-agent-app/providers/fastapi_provider.py b/example/ai-agent-app/providers/fastapi_provider.py index cfc9542e..3ea4ba97 100644 --- a/example/ai-agent-app/providers/fastapi_provider.py +++ b/example/ai-agent-app/providers/fastapi_provider.py @@ -20,4 +20,5 @@ def boot(self) -> None: inertia.version("1.0.0") from routes.web import router + self.app.include_router(router) diff --git a/example/ai-agent-app/routes/web.py b/example/ai-agent-app/routes/web.py index 8173d28c..7a68bcd4 100644 --- a/example/ai-agent-app/routes/web.py +++ b/example/ai-agent-app/routes/web.py @@ -7,9 +7,12 @@ async def index(request: Request): - return Inertia.render("Dashboard/Index", { - "message": "Welcome to AI Agent App", - }) + return Inertia.render( + "Dashboard/Index", + { + "message": "Welcome to AI Agent App", + }, + ) async def chat_page(request: Request): @@ -37,4 +40,3 @@ def messages(self): router.get("/", index) router.get("/chat", chat_page) router.post("/chat", chat_send) -