Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
556f612
feat(ai): add TestJudgeAgent trajectory-match evaluator (evals) (#183)
tmgbedu Jul 16, 2026
4bf8f38
feat(ai): Ai model registry + fluent record() testing DSL (#184)
tmgbedu Jul 17, 2026
05647ee
refactor(ai): back assert_response_judged with a JudgeAgent (#185)
tmgbedu Jul 17, 2026
cf6bf43
refactor: move utils/structures.py into support/
tmgbedu Jul 17, 2026
62fc8a7
Merge pull request #186 from fastapi-startkit/task/audit-utils-struct…
tmgbedu Jul 17, 2026
0b7b7f4
refactor(ai): drop AgentBinding, have fake()/record() return their fa…
tmgbedu Jul 17, 2026
89f3bae
fix(agents): re-record stale/incomplete cassettes for the record() tests
tmgbedu Jul 17, 2026
c439527
Merge pull request #188 from fastapi-startkit/task/agent-fake-record-…
tmgbedu Jul 17, 2026
8a56a62
feat: ai package fix
tmgbedu Jul 23, 2026
58e2d13
feat: fix the ai chat
tmgbedu Jul 24, 2026
f111e8e
AI record(): capture full per-turn interaction (messages + latency + …
tmgbedu Jul 25, 2026
cb9dd29
AI: add agent.assert_tokens() for accumulated token limits (#200)
tmgbedu Jul 25, 2026
526b709
Merge remote-tracking branch 'origin/main' into ai-package-fix
tmgbedu Jul 26, 2026
4131942
test: relocate test_structures to tests/support and fix import
tmgbedu Jul 26, 2026
e164604
feat: add fixes
tmgbedu Jul 27, 2026
d3f39ac
feat(ai)!: agents speak the LangChain create_agent contract
tmgbedu Jul 29, 2026
5fead02
feat(ai): Pest-evals-style assertions on the agent test double
tmgbedu Jul 30, 2026
6d934f8
style: ruff format the eval assertions and pre-existing unformatted t…
tmgbedu Jul 30, 2026
7f2c056
fix(ai): keep pre-existing judge verdicts replayable
tmgbedu Jul 30, 2026
6c9c70a
Merge pull request #203 from fastapi-startkit/task/ai-eval-assertions
tmgbedu Aug 1, 2026
69ff2d5
feat(ai): agent temperature + instance-level record(); example eval f…
tmgbedu Aug 1, 2026
3e7957f
feat: fix
tmgbedu Aug 2, 2026
e7033ed
chore: stop tracking .vscode/settings.json
tmgbedu Aug 2, 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
Expand Up @@ -20,3 +20,4 @@ application
.agents
.uv_cache
.claude/
**/.vscode/settings.json
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
130 changes: 130 additions & 0 deletions example/agents/app/agents/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import json

from fastapi_startkit.ai import Middleware
from fastapi_startkit.ai import state as ai_state
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
from langchain_core.tools import BaseTool

from app.agents.remember import RememberMixin, ThreadAgent
from app.agents.state import Context, RouterOutput
from app.middleware.agent_logger import AgentLogger
from app.models.message import Message
from app.tools.job_search_tool import job_search_tool

# Stand-in for a real profile lookup; JobSearchAgent injects it when the router
# asked for it via `contexts`.
USER_PROFILE = {
"name": "Alice",
"title": "Python Developer",
"location": "Remote",
"skills": ["Python", "FastAPI", "PostgreSQL"],
}

JOB_SEARCH_PROMPT = """You find jobs for a user. ALWAYS call job_search_tool exactly once —
never reply with text only, and never ask clarifying questions.

Derive role/skill/location keywords from the LATEST user message only (e.g. 'python
developer remote'); it replaces any earlier search. Use earlier messages solely for
preferences like location or remote. Ignore filler words like 'suggest', 'me', 'jobs'.
If the latest message has no usable keywords, take them from the user's profile."""

SUMMARIZER_PROMPT = """Write a short, friendly summary of these job listings for the user.
Group by role type, mention company and location. If the list is empty, say so plainly.
Use only what is given - do not invent jobs."""

ROUTER_PROMPT = """You route a career assistant's queries. Pick exactly one intent.

- job_search: the user wants job listings / openings / roles — in ANY form, however
vague. "any jobs", "suggest me jobs", "show openings", "jobs remote", or a bare
"jobs" all count. A vague ask is STILL job_search: never ask the user to narrow it
down — set include_user_profile and let the search use their profile.
- company_research: the user asks about a specific company.
- chat: ONLY greetings (hi / hello), thanks, or small talk with no job intent.

Fill `reply` only for chat. For job_search leave `reply` empty and do NOT ask a
clarifying question — the search node handles vague queries.

Also decide which extra context the next node needs:
- include_user_profile: the query is vague about role, skills or location, so the user's own profile helps.
- include_last_job_search_response: the user is refining an earlier search.
"""


class JobSearchRouterAgent(RememberMixin, ThreadAgent):
"""Classifies a query into a RouterOutput via structured output, answering
greetings inline through its `reply` field. Context: only the user input."""

model = "gemini-3.1-flash-lite" # match the graph's model, not the lab default

def instructions(self) -> str:
return ROUTER_PROMPT

def schema(self) -> type:
return RouterOutput

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

async def remember(self, state: dict) -> None:
# The router's output is routing state, except when it answers a greeting
# inline — that reply is conversation, so it goes on the thread.
decision = ai_state.structured(state)
if decision and decision.intent == "chat" and decision.reply:
await self.remember_row("text", {"text": decision.reply}, state)


class JobSummarizerAgent(RememberMixin, ThreadAgent):
"""Summarizes a job list into prose. Context: only the tool response it is
prompted with — no history, no profile, no tools."""

model = "gemini-3.1-flash-lite" # match the graph's model, not the lab default

def instructions(self) -> str:
return SUMMARIZER_PROMPT

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


class JobSearchAgent(RememberMixin, ThreadAgent):
tool_choice = "any"

@property
def contexts(self) -> list[Context]:
return self.state.get("contexts") or []

async def messages(self) -> list[BaseMessage]:
messages: list[BaseMessage] = []

if Context.INCLUDE_USER_PROFILE in self.contexts:
messages.append(SystemMessage(content=f"User profile: {json.dumps(USER_PROFILE)}"))

rows = list(await Message.where("thread_id", self.thread_id).where("role", "user").order_by("id").get())
# The current turn's user row is persisted before the run; the runner appends
# the live query itself, so drop it from history to avoid sending it twice.
if rows and rows[-1].data.get("text") == self.state.get("query"):
rows = rows[:-1]
messages.extend(HumanMessage(content=row.data.get("text", "")) for row in rows)

if Context.INCLUDE_LAST_JOB_SEARCH_RESPONSE in self.contexts and (last := await self._last_job_search()):
messages.append(AIMessage(content=f"Previous job search results: {json.dumps(last.data)}"))

return messages

async def _last_job_search(self) -> Message | None:
return (
await Message.where("thread_id", self.thread_id)
.where("role", "ai")
.where("type", "tool_response")
.order_by("id", "desc")
.first()
)

def instructions(self) -> str:
return JOB_SEARCH_PROMPT

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

def tools(self) -> list[BaseTool]:
return [job_search_tool]
17 changes: 0 additions & 17 deletions example/agents/app/agents/chat.py

This file was deleted.

143 changes: 143 additions & 0 deletions example/agents/app/agents/job_search_graph.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import json
from typing import cast

from fastapi_startkit.ai import state as ai_state
from langchain_core.runnables import RunnableConfig
from langgraph.graph import END, START, StateGraph
from langgraph.graph.state import CompiledStateGraph

from app.agents.state import InputState, OutputState, OverallState, RouterOutput

# Stand-in for a real profile lookup; the router decides when it's needed.
USER_PROFILE = {
"name": "Alice",
"title": "Python Developer",
"location": "Remote",
"skills": ["Python", "FastAPI", "PostgreSQL"],
}


async def router_node(state: InputState, config: RunnableConfig) -> dict:
"""Context: only the user input. No history, no profile, no tool output.

Handles greetings itself: for the 'chat' intent it answers inline via
RouterOutput.reply, so no extra node/LLM call is needed.
"""
from app.agents.agent import JobSearchRouterAgent

response = await JobSearchRouterAgent(state, config).prompt(state["query"])
decision = cast(
RouterOutput, ai_state.structured(response) or RouterOutput.model_validate_json(ai_state.text(response))
)

out: dict = {"intent": decision.intent, "contexts": decision.contexts}
if decision.intent == "chat":
out |= {"type": "text", "data": decision.reply, "data_type": "string"}
return out


def route(state: OverallState) -> str:
# chat is already answered inline by the router; company_research isn't built yet.
return "job_search" if state["intent"] == "job_search" else END


# --- job search ------------------------------------------------------------


async def job_search_node(state: OverallState, config: RunnableConfig) -> dict:
"""Context: the thread's history + the user profile — both owned by JobSearchAgent.

Delegates the tool-calling loop to JobSearchAgent.prompt(), then pulls the raw job
rows out of the response's tool_events (the summarizer node turns them into prose).
Also reports the calls it made — {"name", "args"} — so they can be shown/persisted.
"""
from app.agents.agent import JobSearchAgent

response = await JobSearchAgent(state, config).prompt(state["query"])

jobs: list[dict] = []
calls: list[dict] = []
for event in ai_state.tool_events(response):
if event["name"] == "job_search_tool":
jobs.extend(json.loads(event["content"]))
calls.append({"name": event["name"], "args": event["args"]})

return {"type": "tool_response", "data": jobs, "data_type": "json", "tool_calls": calls}


# --- ask user (human-in-the-loop) ------------------------------------------


def ask_user_node(state: OverallState) -> dict:
"""No jobs found: pause the graph and ask the frontend to refine the search.

interrupt() freezes the run (state is persisted by the checkpointer) and
surfaces this payload to the caller. The graph resumes when the caller sends
Command(resume=<reply>), and interrupt() returns that reply here.
"""
from langgraph.types import interrupt

reply = interrupt(
{
"type": "question",
"reason": "no_jobs",
"message": f"No jobs matched '{state['query']}'. Try different keywords, or send blank to stop.",
}
)

return {"query": str(reply).strip(), "data": None}


def after_ask(state: OverallState) -> str:
return "job_search" if state["query"] else "job_summarizer"


# --- job summarizer --------------------------------------------------------


async def job_summarizer_node(state: OverallState, config: RunnableConfig) -> dict:
"""Context: only the tool response. Never sees the query or the profile."""
if not state["data"] and state["query"]:
# ask_user still wants a refined query; don't spend a call summarizing nothing
return {}

from app.agents.agent import JobSummarizerAgent

response = await JobSummarizerAgent(state, config).prompt(json.dumps(state["data"] or []))

return {"type": "text", "data": ai_state.text(response), "data_type": "string"}


def ask_or_finish(state: OverallState) -> str:
return "ask_user" if not state["data"] and state["query"] else END


def build() -> StateGraph:
builder = StateGraph(OverallState, input_schema=InputState, output_schema=OutputState)

builder.add_node("router", router_node)
builder.add_node("job_search", job_search_node)
builder.add_node("ask_user", ask_user_node)
builder.add_node("job_summarizer", job_summarizer_node)

builder.add_edge(START, "router")
builder.add_conditional_edges("router", route, ["job_search", END])
builder.add_edge("job_search", "job_summarizer")
builder.add_conditional_edges("job_summarizer", ask_or_finish, ["ask_user", END])
builder.add_conditional_edges("ask_user", after_ask, ["job_search", "job_summarizer"])

return builder


_graph: CompiledStateGraph | None = None


async def job_graph() -> CompiledStateGraph:
"""Compile once, backed by the container's checkpointer (needed for interrupts)."""
global _graph
if _graph is None:
from fastapi_startkit.application import app

checkpointer = await app().make("checkpointer")
_graph = build().compile(checkpointer=checkpointer)
return _graph
60 changes: 60 additions & 0 deletions example/agents/app/agents/remember.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import json

from fastapi_startkit.ai import Agent
from fastapi_startkit.ai import state as ai_state
from langchain_core.runnables import RunnableConfig

from app.agents.state import OverallState
from app.models.message import Message
from app.models.thread import Thread


class ThreadAgent(Agent):
"""An agent bound to a conversation thread via the graph's (state, config)."""

def __init__(self, state: OverallState | None = None, config: RunnableConfig | None = None):
super().__init__()
self.state: dict = dict(state or {})
configurable = (config or {}).get("configurable") or {}
self.thread_id: str = configurable.get("thread_id", "")
self.turn_id: str | None = configurable.get("turn_id")


class RememberMixin:
"""Persists what the agent did — tool calls, tool responses and text replies —
onto its thread, right after each prompt(). No thread_id -> remembers nothing.
"""

async def prompt(self, message: str, **kwargs) -> dict:
state = await super().prompt(message, **kwargs)
await self.remember(state)
return state

async def remember(self, state: dict) -> None:
events = ai_state.tool_events(state)
for event in events:
await self.remember_row("tool_call", {"name": event["name"], "args": event["args"]}, state)
await self.remember_row("tool_response", json.loads(event["content"]), state)

# A plain text reply; structured output is state, not conversation.
text = ai_state.text(state)
if text and not events and ai_state.structured(state) is None:
await self.remember_row("text", {"text": text}, state)

async def remember_row(self, type_: str, data: dict | list, state: dict) -> None:
if not self.thread_id: # not bound to a conversation -> nothing to remember
return
await Thread.first_or_create({"id": self.thread_id}, {"id": self.thread_id})
row: dict = {
"thread_id": self.thread_id,
"role": "ai",
"type": type_,
"data": data,
"data_type": "string" if type_ == "text" else "json",
"run_id": self.turn_id,
"meta": {"agent": type(self).__name__, **({"model": self.model} if self.model else {})},
}
usage = ai_state.usage(state)
if usage["input"] or usage["output"]:
row["usage"] = usage
await Message.create(row)
Loading
Loading