Chat Agent#
Tech Stack Used#
Tech |
Purpose |
|---|---|
LangChain ( |
Builds the ReAct agent loop for complex/unknown requests |
|
Local LLM via Ollama (e.g. |
|
Cloud LLM via OpenAI (e.g. |
|
Wraps all LLM calls — intent classification, summarisation, disambiguation |
LangSmith ( |
Distributed tracing — every |
SQLAlchemy ORM |
Reads |
Python |
Parses LLM intent output + tool return values |
|
Generates |
Agentic Concepts Used#
Concept |
Tool / Tech |
Where |
|---|---|---|
LLM Intent Classification |
LangChain LLM + |
|
Confidence-Gated Routing |
|
|
Context Carry-Forward |
Last 6 messages passed to intent LLM |
|
Clarification Before Action |
Missing param check → ask user |
|
Tool Calling |
LangChain |
|
Full Agent Loop Fallback |
|
|
Run Tracing |
|
|
File Breakdown#
agents/chat_agent.py — Single-File Agent#
The Chat Agent lives in one file: agents/chat_agent.py. The public entry point is run_chat().
System Prompt (SYSTEM_PROMPT at line 76)#
The SYSTEM_PROMPT gives the LLM its personality and hard routing rules:
Role: Lead Intelligence Agent for a utility cost consulting firm
Critical tool rules: Greetings, capability questions, confirmations → NO tool call, reply conversationally only
get_leadsargument rules: Explicit tier words required ("high tier","top leads") — never guesstier="high"for generic requestssearch_companiesrules: Only trigger on explicit external discovery requestsResponse rules: Short and direct; never invent company names, scores, or contacts
LLM Factory (_build_llm() at line 134)#
Returns a LangChain chat model based on LLM_PROVIDER setting:
Setting |
Model |
|---|---|
|
|
|
|
temperature=0 is set on both — deterministic output for tool routing.
LangSmith Tracing (_setup_tracing() at line 53)#
Called at module load time (line 69) before any LangChain import initialises its tracer:
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_API_KEY"] = settings.LANGCHAIN_API_KEY
os.environ["LANGCHAIN_PROJECT"] = settings.LANGCHAIN_PROJECT
If LANGCHAIN_API_KEY is not set, tracing is silently skipped.
Intent Classifier (_extract_intent() at line 543)#
Agentic concept: LLM Intent Classification + Context Carry-Forward
One LLM call per user message. The _INTENT_PROMPT at line 477 instructs the LLM to return only a JSON object:
{
"action": "get_leads",
"confidence": 0.92,
"tier": "high",
"industry": "healthcare",
"location": "",
"count": 10
}
Context carry-forward: Last 6 messages (3 turns) from history are injected into the prompt:
User: show me leads
Agent: Found 12 leads with no filters.
User: what about medium?
The LLM reads this and correctly routes “what about medium?” → get_leads(tier="medium").
Action definitions in the prompt: Each action has a PURPOSE description (not just keyword triggers), so the LLM understands semantic meaning:
get_leads= read ALREADY stored data (instant, no API calls)search_companies= external discovery (slow, costs API credits)
Fallback: Any JSON parse failure → {"action": "unknown", "confidence": 0.0}.
Confidence-Gated Routing (in run_chat() at line 657)#
Agentic concept: Confidence-Gated Routing
After _extract_intent() returns, the routing logic checks confidence before acting:
confidence < 0.65 AND action in {get_leads, search_companies, run_full_pipeline, ...}
→ send disambig_prompt to LLM → ask user one clarifying question
→ return immediately, no tool called
confidence ≥ 0.65
→ route to the appropriate action branch
This prevents wrong tool calls and hallucination from misclassification — the agent asks instead of guessing.
Tools (_make_tools() at line 212)#
All 6 tools are created as closures bound to the current db session and run object. The LLM reads each function’s docstring to decide which tool to call and what args to pass.
Tool 1: search_companies(industry, location, count=10) at line 215
1. Set run.status = "scout_running"
2. Log progress to agent_run_logs
3. Call scout_agent.run(industry, location, count, db)
4. Query Company rows for returned company_ids
5. Write results["companies"] list
6. Log success/failure to agent_run_logs
7. Return JSON: {found, industry, location}
Tool 2: get_leads(tier="", industry="") at line 275
SQLAlchemy JOIN:
CompanyLEFT JOINLeadScoreOptional filters:
func.lower(Company.industry) == industryandLeadScore.tier == tierReturns top 50 leads sorted by score descending
Writes
results["leads"]
Tool 3: get_outreach_history() at line 317
JOIN
Company+OutreachEventwhereevent_type = "sent"Returns: company name, city, emailed_at, follow_up_number, status
Writes
results["outreach_history"]
Tool 4: get_replies() at line 347
JOIN
Company+OutreachEventwhereevent_type = "replied"Returns: company name, reply_sentiment, first 200 chars of reply_content, replied_at
Writes
results["replies"]
Tool 5: run_full_pipeline(industry, location, count=10) at line 377
1. Set run.status = "scout_running"
2. Call orchestrator.run_full_pipeline(industry, location, count, db)
3. Update run.companies_found, companies_scored, drafts_created
4. Set run.status = "writer_awaiting_approval"
5. Log to agent_run_logs
6. Return JSON summary
Tool 6: approve_leads(company_ids, approved_by="sales_team") at line 421
For each company_id in company_ids:
1. Query latest LeadScore row for company
2. Set score_row.approved_human = True, approved_by, approved_at
3. Set company.status = "approved", updated_at = now
4. db.commit()
5. Return JSON: {approved: N, approved_by}
Action Routing (run_chat() at line 606)#
After confidence check, run_chat() routes by action:
Action |
What happens |
|---|---|
|
Direct |
|
Call tool[1] directly → summarise result with a follow-up LLM call |
|
Call tool[2] directly → summarise result |
|
Call tool[3] directly → summarise result |
|
Check for missing industry/location → clarify if missing → call tool[0] directly |
|
Check for missing industry/location → clarify if missing → call tool[4] directly |
|
Full agent loop handles (complex — needs company ID list) |
|
|
Direct tool calls vs agent loop: For all clearly-classified actions, tools are called directly (tools[N].invoke()). The full ReAct agent loop is only used for unknown — this avoids hallucination risk from the LLM generating wrong arguments in a multi-step loop.
Clarification Before Action (search_companies and run_full_pipeline branches):
missing = []
if not location: missing.append("location (e.g. Buffalo NY)")
if not industry: missing.append("type of companies")
if missing:
→ ask user one short sentence
→ return without calling any tool
Run Tracking#
Three helpers write to agent_runs and agent_run_logs:
_create_run(db, trigger_input, run_id) at line 157:
Inserts
AgentRun(trigger_source="chat", status="started", current_stage="chat")
_log_action(db, run_id, agent, action, status, ...) at line 175:
Appends
AgentRunLogrow with: agent name, action label, status, output_summary, duration_ms, error_message
_finish_run(db, run, status) at line 201:
Sets
run.status+run.completed_at
Full Data Flow#
run_chat(message, db, run_id, history)
│
├─ _create_run() → AgentRun(status="started") in DB
├─ _build_llm() → ChatOllama or ChatOpenAI
├─ _make_tools() → 6 LangChain @tool functions (bound to db + run)
│
├─ _extract_intent() → single LLM call → {action, confidence, tier, industry, location, count}
│ └─ uses last 6 history messages for context carry-forward
│
├─ Confidence gate:
│ confidence < 0.65 → disambiguation LLM call → ask user to clarify → return
│
├─ Route by action:
│ "conversational" → llm.invoke([System + Human]) → reply
│ "get_leads" → tools[1].invoke() → summarise LLM call → reply
│ "get_outreach_history" → tools[2].invoke() → summarise LLM call → reply
│ "get_replies" → tools[3].invoke() → summarise LLM call → reply
│ "search_companies" → missing check → tools[0].invoke() → summarise LLM call → reply
│ "run_full_pipeline" → missing check → tools[4].invoke() → summarise LLM call → reply
│ "unknown" → create_agent() ReAct loop → reply
│
├─ _finish_run() → AgentRun(status="completed") in DB
│
└─ return {reply, data, run_id}
What Gets Written to DB#
Table |
Written by |
Contents |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Every significant action: intent, tool calls, errors, disambiguations |
|
|
|
|
|
|