A LangGraph tool-calling agent that decides whether to retrieve, searches a private knowledge base and the live web as tools, chains them multi-hop, and streams a grounded, cited answer. The pattern behind “answer this — and go find what you need.”
search_knowledge_baseKB4 passagessearch_webWEB5 resultsThe Chat view — the agent’s decision, its KB → web tool trajectory, and the streamed answer with a shared citation space.
Classic RAG retrieves on every turn. Here retrieval is a set of tools the model calls on its own initiative — over two sources, chained across as many hops as the question needs — and skips entirely when the question doesn’t need evidence. The answer is grounded and cited; follow-ups keep context.
The model is handed search_knowledge_base and search_web and calls them itself, writing its own queries — not a fixed retrieve step wired into the graph.
Greetings, thanks, opinions and arithmetic are answered directly — no search. Only questions that need evidence trigger a tool call, shown as an “answered directly” chip when skipped.
A private dense-vector KB of your uploaded docs, and the live web (Tavily or DuckDuckGo). The model picks the right one — or both.
KB and web hits pass through a single registry that hands out global [n] numbers, so [3] means the same passage whatever its origin or hop.
The graph loops agent ⇄ tools: search, read the results, spot a gap, search again — KB → web or web → refined web — before committing to an answer.
A per-turn hop budget removes the tools on the last round to force a final grounded answer, and a recursion_limit backstops it — so an eager agent always terminates.
Each conversation is a thread persisted by a LangGraph SqliteSaver checkpointer — follow-ups keep context across requests and restarts.
Nodes emit NDJSON events; the UI renders each tool step as it runs, the sources as they land, and the answer streaming token by token.
Upload .pdf / .txt / .md or paste text, tune chunking, and build a persisted dense index with local sentence-transformers embeddings.
When tools ran, every factual sentence cites its source and the model won’t invent a number that wasn’t returned — with a Test connection check in Settings.
Model, Bedrock connection, embedding model and the optional Tavily key live server-side in SQLite, edited behind a password — never in the browser.
The same agent drives a streaming terminal loop that shares the thread store with the web UI — --no-web for KB-only, --show-sources to print citations.
Whenever the right answer might live in private documents, on the public web, or both, this is the shape of the work. It’s cheaper than retrieving on every turn, more current than a static index, and every claim traces back to a source you can open.
Ask across your uploaded reports and the live web in one turn — the agent grounds the private parts in your KB and fills current facts from the web.
Answer from policy and product docs, and reach for the web only when the KB is silent or stale — no forced retrieval on “hi, can you help?”
Combine an internal brief with fresh web results, then chain follow-up searches to fill the gaps — each fact cited to its origin.
For anything recent, the model reaches past its training cutoff to the live web and cites the pages — instead of guessing from memory.
A compact, readable implementation of tool-decided retrieval, multi-source citations and multi-hop loops on LangGraph — easy to fork and extend.
A small, dependency-light Python backend and a vanilla-JS frontend with no build step. The only heavyweight is the model itself.
Two nodes: agent runs one LLM turn with the tools available, and tools executes whatever it asked for — a conditional edge loops them until the model answers.
┌───────────────── loop: multi-hop ─────────────────┐
▼ │
START ─▶ agent ──has tool calls?──▶ tools ──▶ search_knowledge_base · search_web
│ (results, cited [n])
no tool calls │
▼
grounded, cited answer ─▶ END chit-chat: answer directly, no retrieval
The agent node runs one streamed LLM turn with the two tools available. If it returns no tool calls, that is the answer — the chit-chat / general-knowledge path — and the graph ends. The system prompt tells it to skip retrieval when the question needs no evidence.
When it decides to retrieve, the model emits a tool call and writes the search query itself, resolving pronouns from the conversation (“and its pricing?” → “Acme Cloud pricing”). It picks search_knowledge_base for your docs, search_web for current or general facts — or both.
The tools node runs each call — dense cosine search over the KB, or a Tavily / DuckDuckGo web query — and funnels every hit through one SourceRegistry. Each hit gets the next global [n], so the numbers the model reads are exactly the ones the UI shows.
A conditional edge routes back to agent after every tools round, so the model can read what it found and search again — that’s the multi-hop chain. A per-turn hop budget drops the tools on the final round to force an answer, and a recursion_limit guarantees termination.
On the turn with no tool calls, the model streams the answer, citing every factual sentence with the [n] markers from the shared registry — refusing to invent a citation that wasn’t returned. If nothing was found, it says so plainly.
The whole exchange — tool calls, results, answer — persists per thread_id via the SqliteSaver checkpointer, so follow-ups keep context. Nodes emit stage, tool_call, tool_result, text_delta, no_retrieval and usage events as NDJSON; the browser and CLI render the same stream.
Ships with a pre-seeded app_config.db, so the Bedrock region, key, model and Tavily key carry over — it runs out of the box.
# install & launch pip install -r requirements.txt python server.py # → http://127.0.0.1:8200 # then, in the browser: 1. Knowledge Base → upload .pdf / .txt / .md (optional — web works without it) 2. Chat → try “hi” (no search) vs. a real question (KB + web, multi-hop) 3. Settings → model, Bedrock & Tavily connection (password: bala@143) # or drive the same agent from the terminal python chat.py --show-sources