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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"python.languageServer": "Default"
}
4 changes: 4 additions & 0 deletions example/agents/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ storage
node_modules
/public/build
/public/hot
.ruff_cache
.pytest_cache
.ai
.vite
19 changes: 17 additions & 2 deletions example/agents/app/agents/chat.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
from typing import Callable

from fastapi_startkit.ai import Agent, Middleware
from fastapi_startkit.ai import Agent, GraphAgent, Middleware

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


class RouterAgent(Agent):
class RouterAgent(GraphAgent):
def graph(self):
graph = StateGraph(MessagesState)

# Add nodes
graph.add_node("llm_call", llm_call)
graph.add_node("tool_node", tool_node)

# Add edges to connect nodes
graph.add_edge(START, "llm_call")
graph.add_conditional_edges("llm_call", should_continue, ["tool_node", END])
graph.add_edge("tool_node", "llm_call")

# Compile the agent
agent = graph.compile()

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

Expand Down
9 changes: 9 additions & 0 deletions example/agents/app/agents/graph_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from fastapi_startkit.ai import GraphAgent, GraphRunner
from langgraph.graph import StateGraph


class SalesAgent(GraphAgent):
def checkpointer(self):
pass
def graph(self, runner: GraphRunner) -> StateGraph:
return StateGraph()
44 changes: 44 additions & 0 deletions example/agents/app/providers/langchain_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from fastapi_startkit.support import Provider
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from psycopg.rows import dict_row
from psycopg_pool import AsyncConnectionPool


class LazyCheckpointer:
"""An awaitable, lazily-initialised async Postgres checkpointer.

``Provider.boot()`` runs synchronously while the application is being
constructed — before any serving event loop exists. Async Postgres
connections are bound to the event loop that opens them, so the pool
cannot be opened at boot time. Instead we build the pool closed and open
it (and run ``setup()``) on first ``await`` inside the request loop,
caching the ready saver for subsequent calls.

Usage: ``checkpointer = await app().make("checkpointer")``
"""

def __init__(self, uri: str):
self._pool = AsyncConnectionPool(
conninfo=uri,
open=False,
kwargs={"autocommit": True, "row_factory": dict_row},
)
self._saver: AsyncPostgresSaver | None = None

async def resolve(self) -> AsyncPostgresSaver:
if self._saver is None:
await self._pool.open()
saver = AsyncPostgresSaver(self._pool)
await saver.setup()
self._saver = saver
return self._saver

def __await__(self):
return self.resolve().__await__()


class LangChainProvider(Provider):
DB_URI = "postgresql://postgres:postgres@localhost:5432/agents?sslmode=disable"

def boot(self):
self.app.bind("checkpointer", LazyCheckpointer(self.DB_URI))
10 changes: 4 additions & 6 deletions example/agents/app/tools/job_search_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,15 @@
]


@tool
@tool(description="Use this tools if user wants to search for jobs")
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
)
job
for job in jobs
if any(fnmatch.fnmatch(" ".join(str(v) for v in job.values()).lower(), pattern) for pattern in patterns)
]
2 changes: 2 additions & 0 deletions example/agents/bootstrap/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@
from config.logging import LoggingConfig
from config.vite import ViteConfig
from app.providers.fastapi_provider import FastapiProvider
from app.providers.langchain_provider import LangChainProvider

app: Application = Application(
base_path=Path(__file__).resolve().parent.parent,
providers=[
AISkillProvider,
LangChainProvider,
(LogProvider,LoggingConfig),
(FastapiProvider, FastAPIConfig),
AIProvider,
Expand Down
1 change: 1 addition & 0 deletions example/agents/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dependencies = [
"fastapi-startkit[ai,database,fastapi,sqlite]==0.44.0",
"langchain>=1.3.10",
"langchain-google-genai>=4.2.5",
"langgraph-checkpoint-postgres>=3.1.0",
]

[tool.uv]
Expand Down
5 changes: 5 additions & 0 deletions example/agents/routes/api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse
from fastapi_startkit.inertia import Inertia
from langchain.agents import create_agent

from app.agents.chat import RouterAgent
from app.requests.chat import ChatRequest
Expand All @@ -20,6 +21,10 @@ async def index(request: Request):

@api.post("/chat")
async def chat(request: ChatRequest):
from fastapi_startkit.application import app

agent = create_agent(checkpointer=await app().make("checkpointer"))

response = await RouterAgent().prompt(request.message)
return {"content": response.content}

Expand Down
6 changes: 6 additions & 0 deletions example/agents/tests/features/job_search.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"4fd116f6d1844cc5e3bfb521bf361bf66ac050cf5e97bc506d66cc8fbf7ade43": {
"content": "[{\"id\": 2, \"title\": \"Frontend Developer\", \"location\": \"Remote\", \"company\": \"Startup Inc\", \"type\": \"Full-time\"}]",
"tool_calls": []
}
}
5 changes: 4 additions & 1 deletion example/agents/tests/features/record_no_stream.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
{
"1c27b2b038cae60eab7297ec0a808bfc67246c77a56a9e7c5ce1d9e5fdee0ba3": "Hi Alex, it's a pleasure to meet you! How can I help you today?"
"09e2753147eb813860891b4dd62050bceb302309d3f6db70019be2fb32582a47": {
"content": "Hi Alex, thanks for reaching out! How can I help you today?",
"tool_calls": []
}
}
4 changes: 2 additions & 2 deletions example/agents/tests/features/record_stream.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"32e9c85d324f4cadef79b130717a28d0e23a85a2ac349ad2b1b78a233b31dbc4": [
"97a14ff94f83842c3bf7a706f945aa10c9c6799ae651a74191ffc744f933a59f": [
"Hi",
" Bedram, it's a pleasure to assist you today! How may I help you?"
" Bedram, thanks for reaching out! How can I help you today?"
]
}
20 changes: 15 additions & 5 deletions example/agents/tests/features/test_chat_controller.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
from dumpdie import dd

from app.agents.chat import RouterAgent

from tests.test_case import TestCase


class TestChatController(TestCase):
@RouterAgent.fake({"*hello*": "Hello there, This is no stream chat, Hope you are doing well."})
@RouterAgent.fake(["Hello there, This is no stream chat, Hope you are doing well."])
async def test_it_responds_without_stream(self):
response = await self.post("/chat", json={"message": "hello"})

response.assert_ok()
response.assert_contents("Hello there, This is no stream chat, Hope you are doing well.")

@RouterAgent.fake({"*hello*": "Hello there, This is no stream chat, Hope you are doing well."})
@RouterAgent.fake(["Hello there, This is no stream chat, Hope you are doing well."])
async def test_stream_assertions_are_rejected_on_a_buffered_response(self):
response = await self.post("/chat", json={"message": "hello"})

Expand All @@ -21,24 +23,32 @@ async def test_stream_assertions_are_rejected_on_a_buffered_response(self):
with self.assertRaises(AssertionError):
response.assert_stream("Hello there, This is no stream chat, Hope you are doing well.")

@RouterAgent.fake({"*hello*": "Hello there, This is stream chat, Hope you are doing well."})
@RouterAgent.fake(["Hello there, This is stream chat, Hope you are doing well."])
async def test_it_responds_with_stream(self):
response = await self.post("/chat/stream", json={"message": "hello"})

response.assert_ok()
response.assert_stream_contains('Hello there, This is stream chat, Hope you are doing well.')

@RouterAgent.record("record_no_stream.json")
@RouterAgent.log("record_no_stream.json")
async def test_it_records_without_stream(self):
response = await self.post("/chat", json={"message": "Hi, I am Alex, This is unittest, Please respond by calling my name."})
response.assert_ok()
response.assert_contents("Alex")

@RouterAgent.record("record_stream.json")
@RouterAgent.log("record_stream.json")
async def test_chat_responds_for_other_greetings(self):
response = await self.post("/chat/stream", json={
"message": "Hi, I am Bedram, This is unittest, Please respond by calling my name."
})

response.assert_ok()
response.assert_stream_contains("Bedram")

@RouterAgent.log("job_search.json")
async def test_user_can_perform_the_job_search(self):
response = await self.post("/chat", json={
"message": "suggest me python developer jobs"
})

response.assert_ok()
10 changes: 10 additions & 0 deletions example/agents/tests/units/agents/record_stream.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"034751ef1322a12f3406dad43313f9cdb31f4ea85d9452c22bf7529ad8e92614": {
"content": "Hi there! How can I help you today?",
"tool_calls": []
},
"aaa92579b146c573996a42ba725a88d59fa731fa932e869862db333d4bc70d02": {
"content": "[{\"id\": 2, \"title\": \"Frontend Developer\", \"location\": \"Remote\", \"company\": \"Startup Inc\", \"type\": \"Full-time\"}]",
"tool_calls": []
}
}
26 changes: 26 additions & 0 deletions example/agents/tests/units/agents/test_langchain_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from dumpdie import dd
from langchain.agents import create_agent
from langchain_core.language_models import GenericFakeChatModel

from tests.test_case import TestCase


class Agent:
async def prompt(self, input):
from fastapi_startkit.application import app

agent = create_agent(
model=GenericFakeChatModel(messages=iter(["hello"])),
system_prompt="You are a helpful assistant",
checkpointer=await app().make("checkpointer"),
)

return await agent.ainvoke(
{"messages": [{"role": "user", "content": "hello"}]}, {"configurable": {"thread_id": "1"}}
)


class TestLangchainAgent(TestCase):
async def test_the_router_agent(self):
response = await Agent().prompt("hello")
dd(response)
32 changes: 32 additions & 0 deletions example/agents/tests/units/agents/test_router_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from fastapi_startkit.ai.tinker import ToolCall
from langchain_core.messages import AIMessage, HumanMessage

from app.agents.chat import RouterAgent
from tests.test_case import TestCase


class TestRouterAgent(TestCase):
async def test_the_router_agent(self):
with RouterAgent.record("record_stream.json") as agent:
await agent.prompt("hello")
agent.assert_text_response()
agent.assert_tool_not_called(["job_search_tool"])

agent.assert_response_time_lt(5)

def assert_tool_calls(tool: ToolCall):
return tool.name == "job_search_tool"

await agent.prompt("suggest python developer jobs")
agent.assert_tool_called("job_search_tool", assert_tool_calls)

async def test_the_router_with_initial_messages(self):
with RouterAgent.record(
"record_stream.json",
messages=[
HumanMessage(content="Hi"),
AIMessage(content="Hello, How can I help you?"),
],
) as agent:
await agent.prompt("suggest python developer jobs")
agent.assert_tool_called("job_search_tool", lambda tool: tool.name == "job_search_tool")
62 changes: 62 additions & 0 deletions example/agents/tinker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import asyncio
import operator
from collections.abc import Callable
from typing import Annotated, TypedDict

from dumpdie import dump
from langchain.agents import create_agent
from langchain_core.messages import AnyMessage
from langgraph.checkpoint.memory import InMemorySaver

from app.tools.job_search_tool import job_search_tool
from bootstrap.application import app # NOQA


class ChatState(TypedDict):
messages: Annotated[list[AnyMessage], operator.add]
llm_calls: int


agent = create_agent(
model="google_genai:gemini-3.1-flash-lite",
checkpointer=InMemorySaver(),
tools=[job_search_tool],
)


async def prompt(message: str):
config = {"configurable": {"thread_id": "1"}}
return await agent.ainvoke(input={"messages": [{"role": "user", "content": message}]}, config=config)


class Agent:
def __init__(self, prompt_handler=Callable):
self.prompt_handler = prompt_handler

async def prompt(self, message):
pass

async def ainvoke(self):
await prompt(message="suggest me frontend developer jobs")


async def main():
response = await prompt(message="Hello, world!")
dump(response["messages"])
response = await prompt(message="suggest me frontend developer jobs")
dump(response["messages"])


asyncio.run(main())


# def test_it_can_prompt():
# with Agent(prompt_handler=prompt) as agent:
# agent.prompt("hi") # hit the real end point for the first time, records the responses
# agent.assert_prompted("hi")
# agent.assert_prompt_judged(model="", expectation="")
#
# agent.prompt("suggest me python developer jobs") # hit for the first time and second records will be recorded
# agent.assert_tool_called(
# lambda tool: tool.name == "job_search_tool" and tool.args == {"query": "python developer jobs"}
# )
Loading
Loading