This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
A Python multi-agent orchestration system built on LangChain/LangGraph, exposed via three interfaces (REST API, CLI, MCP server) that all share the same agent/routing/DB layer. Observability is via Langfuse; models are accessed through LiteLLM (currently Gemini 2.5 Flash).
# Setup
uv venv venv
source venv/bin/activate
touch main.db # create empty sqlite db
uv sync # install deps from pyproject.toml
# Run REST API (http://127.0.0.1:8000, docs at /docs)
uvicorn app.main:app --reload
# Run interactive CLI
python -m cli
# Run MCP server (stdio, for Claude Desktop / Cursor)
./venv/bin/python mcp_server.py
# Run MCP server (SSE/HTTP, http://localhost:8000/sse)
python -m mcp_server sse
# Run all tests
PYTHONPATH=. pytest
# Run a single test file / test
PYTHONPATH=. pytest tests/test_weather_agent.py
PYTHONPATH=. pytest tests/test_weather_agent.py::test_choose_agent_routingUse ./venv/bin/python / ./venv/bin/pytest explicitly when the venv isn't activated (per .agents/AGENTS.md).
Tests set required env vars (mock API keys, DATABASE_URL=sqlite:///main_test.db) in tests/conftest.py, and use an in-memory SQLite engine + app.dependency_overrides per test module — no .env needed to run the suite.
app/main.py (FastAPI), cli.py, and mcp_server.py are thin wrappers around the same core: app.agents.choose_agent for routing + LangGraph agent graphs (car_hire_agent, weather_agent, generate_image_agent, marketing_agent, rag_ingest_agent, rag_query_agent) for execution. All three entrypoints duplicate the same per-agent "build initial state / invoke / handle next_question" dispatch logic — when changing an agent's state shape or adding an agent, update all three call sites plus app/agents/__init__.py.
POST /llm-job(app/routes/llm_job.py) creates anLLMJobrow (app/models/llm_job.py), statusqueue.choose_agent()(app/agents/agent_chooser.py) routes the prompt. Before calling the LLM it short-circuits throughapp/agents/agent_memory.py:try_handle_profile— handles "remember X" / "who am I" locally.check_prompt_cache— exact-match cache for priordirect_answer/imageresponses, and a 15-minute TTL cache forweather.- Only if neither hits does it call Gemini with structured output to pick an action (
car_hire_agent/weather_agent/generate_image_agent/marketing_agent/rag_ingest_agent/rag_query_agent/direct_answer/unsupported). Before this call it also fetches the most recentRagDocumentURLs (get_known_rag_sources) and includes them in the router's system prompt, so questions about previously-ingested sites route torag_query_agentinstead ofdirect_answer/unsupported.
- Direct/unsupported answers finish the job immediately (status
done). Otherwise the matching LangGraph agent is invoked withconfig={"configurable": {"session": ..., "user_id": ...}}so agent nodes can read/writeLLMMemory(app/models/llm_memory.py) via the DB session. - If the agent graph returns
next_question(a field on itsTypedDictstate), the job goes to statusawaiting_inputand the response is the follow-up question; the client resumes it viaPATCH /llm-job/{id}with{"answer": ...}, which re-invokes the same agent (agent_nameread back fromjob.state["agent"]) with prior state fields merged in. Otherwise the job isdone(orerrorif the agent raisedAppException/an exception).
Each agent (car_hire_agent.py, weather_agent.py, generate_image_agent.py, marketing_agent.py) follows the same shape: a TypedDict state, Pydantic schemas for structured LLM output, node functions built as ChatPromptTemplate | llm.with_structured_output(...), and a compiled StateGraph with conditional edges for the human-in-the-loop (missing_fields/next_question) or approval (marketing_agent's approved_option) pattern. Every LLM-calling node passes get_langfuse_handler() as a callback and tags calls with langfuse_tags: [agent_name, node_name] — follow this pattern for new nodes/agents so traces stay attributable in Langfuse.
rag_ingest_agent.py/rag_query_agent.py are a linear (no HITL) pair implementing the URL-RAG flow from specs/url-rag.md: rag_ingest_agent regex-extracts a URL from the prompt (no LLM call — deterministic and cheaper than structured extraction for this), skips re-scraping if the URL is already in RagDocument, otherwise scrapes with httpx + beautifulsoup4, splits with langchain_text_splitters.RecursiveCharacterTextSplitter, and embeds/persists chunks via GoogleGenerativeAIEmbeddings (app.config.GEMINI_EMBEDDING_MODEL, reuses GEMINI_API_KEY). rag_query_agent embeds the question, ranks all RagChunk rows by cosine similarity (plain Python, no vector store — retrieval is brute-force, fine at this scale but not built to scale past a small KB), and synthesizes an answer from the top matches, falling back to a fixed "don't know" response below the similarity threshold rather than hallucinating.
agent_chooser.py is the router; new agents must be registered in its system prompt (action name + routing description), re-exported from app/agents/__init__.py, and wired into the dispatch blocks in app/main.py/routes/llm_job.py, cli.py, and mcp_server.py.
app/db.py: single SQLModelengine(SQLite by default),init_db()creates tables and seeds two demoLLMJobrows if empty,get_session()is the FastAPI dependency.LLMJob(app/models/llm_job.py): the job/task record —prompt,response,status(queue/awaiting_input/done/error),state(JSON blob holding the active agent name + its in-progress fields for resumption).LLMMemory(app/models/llm_memory.py): generic key/value cache keyed by(user_id, memory_type, query_key)wherequery_keyis normalized vianormalize_key()(lowercased, trimmed, trailing punctuation stripped).memory_typevalues in use:profile,direct_answer,image,weather,weather_data.RagDocument/RagChunk(app/models/rag.py): the URL-RAG knowledge base — oneRagDocumentper ingested URL, oneRagChunkper text chunk with its embedding stored as JSON (Column(JSON), same pattern asLLMJob.state). UnlikeLLMMemory, this isn't per-user_id— all ingested content is shared/global across users.
app/config.pyloads all env vars (via.env) as module-level constants — always add new secrets/settings here rather than readingos.environelsewhere.app/llm_model.pycentralizes model construction (currently onlyget_gemini_2_5_flash_model, usingChatLiteLLM);car_hire_agent.pystill constructsChatLiteLLMdirectly instead of using this helper (inconsistency to be aware of, not necessarily to copy).app/langfuse.pylazily builds and caches a singleCallbackHandler;get_langfuse_handler()returnsNoneif keys aren't configured, and call sites are expected to handle that (config = {...} if handler else {}).app/util.pyprovides a single process-lifetimeget_session_id()used as the Langfuse session id.
From .agents/AGENTS.md (binding for all coding agents in this repo):
- Style: PEP 8,
snake_casefunctions/vars,PascalCaseclasses,UPPER_SNAKE_CASEconstants, 4-space indents, two blank lines around top-level defs/classes, one blank line between methods. - Imports: absolute imports rooted at the project (
from app.config import X, never barefrom config import X); expose package members via__init__.py__all__where applicable (seeapp/models/__init__.py). - SQLite: never use the
COMMENTkeyword in SQL; always manage DB sessions via context managers or FastAPIDepends. - FastAPI/Pydantic: use
lifespan(not@app.on_event), Pydantic v2's.model_dump()(not.dict()),typing.Annotatedfor route dependencies. - Read config from
app.config, notos.environ, directly. - All prompts sent to an LLM must go through Langfuse observability (pass the callback handler +
langfuse_tags/langfuse_session_idmetadata, matching existing node patterns). TODOcomments mark code intentionally left alone — do not change unless explicitly asked.
.agents/skills/ contains reference skills for LangChain/LangGraph/Langfuse/deep-agents patterns (fundamentals, RAG, middleware, persistence, human-in-the-loop, orchestration, etc.). Consult the relevant skill under this directory before making non-trivial changes to agent graphs, memory/persistence, or Langfuse instrumentation.