Module 9: Human-in-the-Loop

Evolving Project: Human Approvals (v4)

Project overview

In Module 8, you built v3 of the AI Research Assistant: an agent with persistent memory, personalized greetings, preference detection, and multi-user support. If you close the terminal and open it again, the agent remembers who you are and what you researched. It's a product with an identity.

But it makes every decision on its own. If it decides to search a premium API that costs $5.00, it does it without asking. If it generates a report with an incorrect fact, it presents that fact as truth. If its research plan is inefficient — searching 6 sources when 2 would do — you have no way to tell it "no, focus on just these two." The agent is competent but unsupervised.

v4 turns the Research Assistant from an autonomous agent into a supervised one. Before searching, it shows its plan: "I plan to search these 3 sources. Estimated cost: $2.50. Shall I proceed?" Before generating the final report, it shows you a draft so you can review it and give feedback. If a fact is wrong, you can edit it directly in the agent's state. The result is an agent that keeps the human in the loop for the decisions that matter, without bothering you about the ones that don't.

The moment that defines this version: the agent shows its research plan, you remove an unnecessary source and add one it hadn't considered. The agent runs your corrected plan, generates a draft, you point out a factual error, the agent fixes it and produces the final report. That iterative conversation between human and agent — that's what effective supervision looks like.


Project goal

Evolve the AI Research Assistant from v3 (smart but autonomous) to v4 (smart and supervised) by adding plan approval, a cost gate, a feedback loop, and state editing.

By the end of this project:

  • 🔧 You'll implement an interrupt in the planning phase to show the plan before executing
  • 🔧 You'll add a cost gate that auto-approves free sources and interrupts for paid ones
  • 🔧 You'll create a feedback loop where the user reviews the draft and gives feedback (3 rounds max)
  • 🔧 You'll enable state editing so the user can correct factual data before the final report
  • 🔧 You'll build an interactive CLI that handles every HITL flow naturally

Before and after

v3 (Module 8): smart but autonomous

User: "Research AI agents"
Agent: [decides the plan internally — 5 sources, $3.50]
Agent: [searches every source without asking]
Agent: [generates a report with an incorrect fact]
Agent: "Here's your report."
User: "... it spent $3.50 and the second finding is wrong"

v4 (This module): smart and supervised

User: "Research AI agents"
Agent: "Plan: search [web, arXiv, news]. Cost: $0.00. Shall I proceed?"
User: "Add academic, drop news"
Agent: [searches web, arXiv, academic — free sources, auto-approved]
Agent: "I can also search Premium Research ($2.50). Shall I proceed?"
User: "No, the free ones are enough"
Agent: "Report draft ready. Any feedback?"
User: "The second finding says 2023 but it's 2025. Fix it."
Agent: [fixes it] → "Final report with the correction."

Technical specs

ComponentVersionPurpose
Python3.11+Runtime
LangChainv1.2+LLM framework
LangGraphv1.0+Functional API + interrupt + Command
langchain-openailatestModel provider
pydanticv2+Structured models

Project structure

research-assistant/
├── .env
├── requirements.txt
├── agents/
│   └── researcher.py           # MODIFIED — v4 with interrupts + feedback
├── tools/
│   ├── web_search.py           # UNCHANGED (from v2)
│   └── calculator.py           # UNCHANGED (from v1)
├── state/
│   └── research_state.py       # EXTENDED — HumanFeedback model
├── config/
│   └── settings.py             # EXTENDED — per-source cost config
├── memory/
│   └── user_store.py           # UNCHANGED (from v3)
├── hitl/                       # NEW
│   └── risk_assessment.py      # Risk decision framework
├── utils/
│   ├── retry.py                # UNCHANGED (from v2)
│   └── logger.py               # UNCHANGED (from v2)
└── main.py                     # MODIFIED — CLI with HITL flows

Step 1: The risk framework (hitl/risk_assessment.py)

The module that decides which actions need human approval and which get auto-approved. It applies the 4-criteria framework from the previous capsule.

"""
hitl/risk_assessment.py
Risk decision framework for the AI Research Assistant v4.
"""

from dataclasses import dataclass
from enum import Enum


class RiskDecision(Enum):
    AUTO_APPROVE = "auto_approve"
    QUICK_APPROVE = "quick_approve"
    FULL_REVIEW = "full_review"


@dataclass
class ActionRisk:
    action_name: str
    cost: float
    reversible: bool
    external_impact: bool
    confidence: float

    @property
    def risk_score(self) -> float:
        if self.cost < 0.01:
            cost_score = 0.0
        elif self.cost < 1.0:
            cost_score = 0.3
        elif self.cost < 10.0:
            cost_score = 0.6
        else:
            cost_score = 1.0

        rev_score = 0.0 if self.reversible else 0.8
        impact_score = 0.7 if self.external_impact else 0.0
        base = max(cost_score, rev_score, impact_score)
        confidence_penalty = (1.0 - self.confidence) * 0.5
        return min(round(base + confidence_penalty, 2), 1.0)

    @property
    def decision(self) -> RiskDecision:
        score = self.risk_score
        if score < 0.3:
            return RiskDecision.AUTO_APPROVE
        elif score <= 0.7:
            return RiskDecision.QUICK_APPROVE
        else:
            return RiskDecision.FULL_REVIEW


SOURCE_COSTS = {
    "web": 0.0,
    "academic": 0.0,
    "news": 0.0,
    "premium_research": 2.50,
    "patent_db": 1.00,
    "financial_data": 5.00,
}


def assess_source(source_type: str, confidence: float = 0.9) -> ActionRisk:
    cost = SOURCE_COSTS.get(source_type, 0.0)
    return ActionRisk(
        action_name=f"search_{source_type}",
        cost=cost,
        reversible=True,
        external_impact=False,
        confidence=confidence,
    )


def assess_report_send(recipients: int, confidence: float = 0.8) -> ActionRisk:
    return ActionRisk(
        action_name="send_report",
        cost=0.0,
        reversible=False,
        external_impact=recipients > 1,
        confidence=confidence,
    )


def partition_sources(sources: list[str], confidence: float = 0.9) -> dict:
    """Splits sources into auto-approved and requires-approval."""
    auto = []
    needs_approval = []
    total_cost = 0.0

    for src in sources:
        risk = assess_source(src, confidence)
        if risk.decision == RiskDecision.AUTO_APPROVE:
            auto.append({"source": src, "cost": SOURCE_COSTS.get(src, 0.0)})
        else:
            needs_approval.append({"source": src, "cost": SOURCE_COSTS.get(src, 0.0), "risk": risk.risk_score})
            total_cost += SOURCE_COSTS.get(src, 0.0)

    return {
        "auto_approved": auto,
        "needs_approval": needs_approval,
        "total_pending_cost": total_cost,
    }

Step 2: Extend the data models (state/research_state.py)

Add the HumanFeedback model to track human interactions during execution.

"""
state/research_state.py
Data models for the AI Research Assistant v4.
"""

from pydantic import BaseModel, Field
from datetime import datetime


class Source(BaseModel):
    name: str
    source_type: str
    content: str


class KeyFinding(BaseModel):
    title: str
    description: str
    confidence: float = Field(ge=0.0, le=1.0)


class SourceStatus(BaseModel):
    source_type: str
    status: str
    attempts: int = Field(default=1)
    error: str = Field(default="")
    duration_ms: float = Field(default=0)


class UserProfile(BaseModel):
    user_id: str
    total_sessions: int = 0
    top_topics: list[tuple[str, int]] = Field(default_factory=list)
    last_topic: str = ""
    preferred_format: str = "paragraphs"
    is_new_user: bool = True


class HumanFeedback(BaseModel):
    """A record of one human interaction during execution (v4)."""
    feedback_type: str
    content: str
    timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())
    round_number: int = 1


class ResearchReport(BaseModel):
    topic: str
    summary: str
    key_findings: list[KeyFinding] = Field(min_length=1)
    sources: list[Source] = Field(min_length=1)
    sub_queries: list[str]
    confidence: float = Field(ge=0.0, le=1.0)
    generated_at: str = Field(default_factory=lambda: datetime.now().isoformat())
    source_availability: list[SourceStatus] = Field(default_factory=list)
    version: str = Field(default="v4")
    user_id: str = Field(default="anonymous")
    session_number: int = Field(default=0)
    human_feedback_rounds: int = Field(default=0)
    plan_was_edited: bool = Field(default=False)
    facts_corrected: int = Field(default=0)


class SubQuery(BaseModel):
    query: str
    rationale: str

Step 3: Cost configuration (config/settings.py)

"""
config/settings.py
Configuration for the AI Research Assistant v4.
"""

from dotenv import load_dotenv
load_dotenv()

MODEL_NAME = "openai:gpt-4.1-mini"
MODEL_TEMPERATURE = 0.2
MAX_SUB_QUERIES = 4
SEARCH_SOURCES = ["web", "academic", "news"]

RETRY_MAX_ATTEMPTS = 3
RETRY_BASE_DELAY = 1.0
RETRY_MAX_DELAY = 10.0
RETRY_JITTER = True
MIN_SOURCES_FOR_REPORT = 1

# v3: Memory
MAX_CONVERSATION_MESSAGES = 20
MEMORY_PERSIST_FILE = "memory_store.json"
DEFAULT_FORMAT = "paragraphs"
DEFAULT_DETAIL_LEVEL = "standard"
DEFAULT_SOURCES_PRIORITY = ["web", "academic", "news"]

# v4: HITL
MAX_FEEDBACK_ROUNDS = 3
PAID_SOURCES = {"premium_research": 2.50, "patent_db": 1.00, "financial_data": 5.00}
FREE_SOURCES = ["web", "academic", "news"]
AUTO_APPROVE_COST_THRESHOLD = 0.01

Step 4: The v4 agent with HITL (agents/researcher.py)

The agent now has four interruption points:

  1. Plan approval — shows the plan and waits for approval/editing
  2. Cost gate — interrupts for paid sources
  3. Draft feedback — shows the draft and waits for feedback (up to 3 rounds)
  4. Fact correction — lets the user edit findings before the final report
"""
agents/researcher.py
AI Research Assistant v4 — human-in-the-loop, supervised execution.
"""

import json
import time
import uuid
from langchain.chat_models import init_chat_model
from langgraph.func import entrypoint, task
from langgraph.checkpoint.memory import MemorySaver
from langgraph.store.base import BaseStore
from langgraph.types import interrupt

import sys
sys.path.insert(0, ".")

from config.settings import (
    MODEL_NAME, MODEL_TEMPERATURE, MAX_SUB_QUERIES,
    MIN_SOURCES_FOR_REPORT, MAX_FEEDBACK_ROUNDS,
    FREE_SOURCES, PAID_SOURCES,
)
from state.research_state import (
    ResearchReport, Source, KeyFinding, SubQuery, SourceStatus, HumanFeedback,
)
from tools.web_search import search_with_retry
from tools.calculator import calculate_confidence
from utils.logger import ResearchLogger
from memory.user_store import (
    get_user_profile, get_preferences, record_session, detect_preferences_from_input,
)
from hitl.risk_assessment import partition_sources, assess_source, RiskDecision

model = init_chat_model(MODEL_NAME, temperature=MODEL_TEMPERATURE)
agent_logger = ResearchLogger("research_agent_v4")


@task
def generate_greeting(user_id: str, profile: dict) -> str:
    if profile["is_new_user"]:
        return "Hi! I'm your research assistant. In this version, I'll check with you before any important decision."

    parts = ["Welcome back!"]
    if profile["last_session"]:
        topic = profile["last_session"].get("topic", "")
        date = profile["last_session"].get("timestamp", "")[:10]
        parts.append(f"Last research: '{topic}' ({date}).")
    if profile["total_sessions"] > 0:
        parts.append(f"You're at {profile['total_sessions']} sessions.")
    if profile["top_topics"] and profile["top_topics"][0][1] >= 2:
        top = profile["top_topics"][0]
        parts.append(f"Favorite topic: '{top[0]}' ({top[1]} times).")

    return " ".join(parts)


@task
def decompose_query(topic: str) -> list[dict]:
    response = model.invoke(
        f"Break this topic into {MAX_SUB_QUERIES} researchable sub-questions.\n\n"
        f"Topic: {topic}\n\n"
        f'Answer in JSON: [{{"query": "...", "rationale": "..."}}]\nJSON only.'
    )
    try:
        return json.loads(response.content)[:MAX_SUB_QUERIES]
    except json.JSONDecodeError:
        return [{"query": topic, "rationale": "Fallback"}, {"query": f"advances in {topic}", "rationale": "Trends"}]


@task
def plan_research(topic: str, sub_queries: list[dict], available_sources: list[str]) -> dict:
    """Builds the research plan and asks for human approval."""
    partition = partition_sources(available_sources)

    plan = {
        "topic": topic,
        "sub_queries": [sq["query"] for sq in sub_queries],
        "free_sources": [s["source"] for s in partition["auto_approved"]],
        "paid_sources": [s for s in partition["needs_approval"]],
        "total_free_cost": 0.0,
        "total_paid_cost": partition["total_pending_cost"],
    }

    response = interrupt({
        "type": "plan_approval",
        "message": (
            f"Research plan for '{topic}':\n"
            f"  Sub-queries: {len(sub_queries)}\n"
            f"  Free sources: {plan['free_sources']}\n"
            f"  Paid sources: {[s['source'] for s in plan['paid_sources']]} "
            f"(${plan['total_paid_cost']:.2f})\n"
            f"Shall I proceed? Options: approve / edit / cancel"
        ),
        "plan": plan,
    })

    action = response.get("action", "approve")

    if action == "cancel":
        return {**plan, "status": "cancelled"}
    elif action == "edit":
        edited_sources = response.get("sources", plan["free_sources"])
        plan["free_sources"] = [s for s in edited_sources if s in FREE_SOURCES]
        plan["paid_sources"] = [
            {"source": s, "cost": PAID_SOURCES.get(s, 0.0)}
            for s in edited_sources if s in PAID_SOURCES
        ]
        plan["total_paid_cost"] = sum(s["cost"] for s in plan["paid_sources"])
        plan["was_edited"] = True
        return {**plan, "status": "approved_edited"}
    else:
        return {**plan, "status": "approved"}


@task
def approve_paid_sources(paid_sources: list[dict]) -> list[str]:
    """Cost gate: asks for individual approval on each paid source."""
    approved = []
    for source_info in paid_sources:
        src = source_info["source"]
        cost = source_info["cost"]

        response = interrupt({
            "type": "cost_approval",
            "message": f"Searching '{src}' costs ${cost:.2f}. Approve?",
            "source": src,
            "cost": cost,
        })

        if response.get("approved", False):
            approved.append(src)
            print(f"   ✅ {src} (${cost:.2f}) — approved")
        else:
            print(f"   ❌ {src} (${cost:.2f}) — rejected")

    return approved


@task
def search_all_sources(query: str, sources: list[str], logger: ResearchLogger) -> list[dict]:
    start = time.time()
    futures = [search_with_retry(query, src, logger) for src in sources]
    results = [f.result() for f in futures]
    return results


@task
def merge_and_deduplicate(all_results: list[dict]) -> list[dict]:
    seen, unique = set(), []
    for r in all_results:
        if r["search_status"] != "ok":
            continue
        key = f"{r['source_type']}:{r['content'][:100]}"
        if key not in seen:
            seen.add(key)
            unique.append(r)
    unique.sort(key=lambda r: r.get("relevance", 0), reverse=True)
    return unique


@task
def synthesize_findings(topic: str, results: list[dict]) -> list[dict]:
    text = "\n".join(f"Source {i} ({r['source_type']}): {r['content']}" for i, r in enumerate(results, 1))
    response = model.invoke(
        f"Identify 3-5 key findings about '{topic}'.\n\nSources:\n{text}\n\n"
        f'JSON: [{{"title": "...", "description": "...", "confidence": 0.8}}]\nJSON only.'
    )
    try:
        return json.loads(response.content)[:5]
    except json.JSONDecodeError:
        return [{"title": "General finding", "description": f"Relevant research on {topic}.", "confidence": 0.6}]


@task
def generate_summary(topic: str, findings: list[dict], source_info: str, user_prefs: dict) -> str:
    findings_text = "\n".join(f"- {f['title']}: {f['description']}" for f in findings)
    fmt = ""
    if user_prefs.get("format") == "bullet_points":
        fmt = "Use bullet points. "
    if user_prefs.get("detail_level") == "brief":
        fmt += "Be brief (1-2 sentences). "
    elif user_prefs.get("detail_level") == "detailed":
        fmt += "Be detailed (4-5 sentences). "

    response = model.invoke(
        f"Executive summary about '{topic}'.\nFindings:\n{findings_text}\n"
        f"Sources: {source_info}\n{fmt}The summary only."
    )
    return response.content.strip()


@task
def collect_draft_feedback(topic: str, summary: str, findings: list[dict], round_num: int) -> dict:
    """Shows the draft and collects the user's feedback."""
    findings_display = "\n".join(
        f"  {i}. {f['title']} [{f['confidence']:.0%}]: {f['description']}"
        for i, f in enumerate(findings, 1)
    )

    response = interrupt({
        "type": "draft_feedback",
        "message": (
            f"--- Report draft (round {round_num}/{MAX_FEEDBACK_ROUNDS}) ---\n\n"
            f"Topic: {topic}\n\n"
            f"Summary:\n{summary}\n\n"
            f"Findings:\n{findings_display}\n\n"
            f"Any feedback? Options: approve / feedback <text> / edit_facts"
        ),
        "summary": summary,
        "findings": findings,
        "round": round_num,
    })

    return response


@task
def apply_feedback_to_summary(topic: str, current_summary: str, feedback: str, findings: list[dict], user_prefs: dict) -> str:
    """Regenerates the summary incorporating the user's feedback."""
    findings_text = "\n".join(f"- {f['title']}: {f['description']}" for f in findings)
    response = model.invoke(
        f"Rewrite this summary about '{topic}' incorporating the user's feedback.\n\n"
        f"Current summary:\n{current_summary}\n\n"
        f"Findings:\n{findings_text}\n\n"
        f"User feedback: {feedback}\n\n"
        f"Produce the improved summary only."
    )
    return response.content.strip()


@task
def collect_fact_corrections(findings: list[dict]) -> list[dict]:
    """Lets the user correct factual findings."""
    response = interrupt({
        "type": "fact_correction",
        "message": (
            "You can correct the findings. Send a list of corrections:\n"
            '  {"corrections": [{"index": 0, "field": "description", "new_value": "..."}]}\n'
            "Or send {\"corrections\": []} if everything is correct."
        ),
        "findings": findings,
    })

    corrections = response.get("corrections", [])
    corrected = [f.copy() for f in findings]

    for correction in corrections:
        idx = correction.get("index", -1)
        field = correction.get("field", "")
        new_value = correction.get("new_value", "")
        if 0 <= idx < len(corrected) and field in corrected[idx]:
            corrected[idx][field] = new_value

    return corrected


def create_research_agent(checkpointer, store):
    """Factory that builds the v4 research agent."""

    @entrypoint(checkpointer=checkpointer, store=store)
    def research_agent(topic: str, *, store: BaseStore) -> dict:
        config = entrypoint.get_config()
        user_id = config["configurable"].get("user_id", "anonymous")
        request_id = uuid.uuid4().hex[:8]
        agent_logger.set_request_id(request_id)

        pipeline_start = time.time()
        profile = get_user_profile(store, user_id)
        user_prefs = get_preferences(store, user_id)
        greeting = generate_greeting(user_id, profile).result()

        print(f"\n{'=' * 60}")
        print(f"  🔬 AI Research Assistant v4 — Supervised")
        print(f"  {greeting}")
        print(f"  Topic: {topic} | User: {user_id} | Request: {request_id}")
        print(f"{'=' * 60}")

        detect_preferences_from_input(store, user_id, topic)

        # --- PHASE 1: Decompose ---
        print(f"\n📋 Decomposing the topic...")
        sub_queries_raw = decompose_query(topic).result()
        sub_queries = [SubQuery(**sq) for sq in sub_queries_raw]
        for i, sq in enumerate(sub_queries, 1):
            print(f"   {i}. {sq.query}")

        # --- PHASE 2: Plan approval (INTERRUPT) ---
        all_available = list(FREE_SOURCES) + list(PAID_SOURCES.keys())
        plan = plan_research(topic, sub_queries_raw, all_available).result()

        if plan.get("status") == "cancelled":
            print(f"\n   ❌ Research cancelled by the user.")
            return {"error": "Research cancelled", "version": "v4"}

        was_edited = plan.get("was_edited", False)
        active_sources = plan["free_sources"]
        print(f"\n   Plan {'edited' if was_edited else 'approved'}: {active_sources}")

        # --- PHASE 3: Cost gate for paid sources (INTERRUPT per source) ---
        if plan["paid_sources"]:
            print(f"\n💰 Evaluating paid sources...")
            approved_paid = approve_paid_sources(plan["paid_sources"]).result()
            active_sources = active_sources + approved_paid
        else:
            print(f"\n   No paid sources in the plan.")

        # --- PHASE 4: Search ---
        print(f"\n🔍 Searching {len(active_sources)} sources: {active_sources}")
        all_raw, source_statuses = [], []
        search_futures = [search_all_sources(sq.query, active_sources, agent_logger) for sq in sub_queries]

        for i, future in enumerate(search_futures):
            results = future.result()
            all_raw.extend(results)
            ok = sum(1 for r in results if r["search_status"] == "ok")
            print(f"   Sub-query {i + 1}: {ok}/{len(results)} OK")
            for r in results:
                source_statuses.append(SourceStatus(
                    source_type=r["source_type"], status=r["search_status"],
                    attempts=r.get("attempts", 1), error=r.get("error", ""),
                    duration_ms=r.get("duration_ms", 0),
                ))

        total_ok = sum(1 for r in all_raw if r["search_status"] == "ok")
        total_failed = len(all_raw) - total_ok

        if total_ok < MIN_SOURCES_FOR_REPORT:
            print(f"\n   ❌ Not enough sources ({total_ok}). Aborting.")
            return {"error": f"Only {total_ok} sources. Minimum: {MIN_SOURCES_FOR_REPORT}", "version": "v4"}

        # --- PHASE 5: Merge + Synthesize ---
        unique = merge_and_deduplicate(all_raw).result()
        print(f"\n🔀 {len(all_raw)} raw → {len(unique)} unique")

        findings_raw = synthesize_findings(topic, unique).result()
        findings = [KeyFinding(**f) for f in findings_raw]
        print(f"🧠 {len(findings)} findings identified")

        source_info = f"{total_ok}/{total_ok + total_failed} sources OK"
        summary = generate_summary(topic, findings_raw, source_info, user_prefs).result()

        # --- PHASE 6: Draft feedback loop (INTERRUPT — max 3 rounds) ---
        feedback_rounds = 0
        human_feedbacks = []

        for round_num in range(1, MAX_FEEDBACK_ROUNDS + 1):
            print(f"\n📝 Showing the draft (round {round_num})...")
            feedback_response = collect_draft_feedback(topic, summary, findings_raw, round_num).result()
            action = feedback_response.get("action", "approve")

            if action == "approve":
                print(f"   ✅ Draft approved on round {round_num}.")
                break

            elif action == "feedback":
                feedback_text = feedback_response.get("text", "")
                human_feedbacks.append(HumanFeedback(
                    feedback_type="draft_feedback",
                    content=feedback_text,
                    round_number=round_num,
                ))
                print(f"   📝 Feedback received: '{feedback_text[:60]}...'")
                summary = apply_feedback_to_summary(topic, summary, feedback_text, findings_raw, user_prefs).result()
                feedback_rounds += 1
                print(f"   🔄 Summary updated.")

            elif action == "edit_facts":
                print(f"\n   ✏️ Fact editing mode...")
                corrected_findings_raw = collect_fact_corrections(findings_raw).result()
                corrections_count = sum(
                    1 for orig, corr in zip(findings_raw, corrected_findings_raw)
                    if orig != corr
                )
                if corrections_count > 0:
                    findings_raw = corrected_findings_raw
                    findings = [KeyFinding(**f) for f in findings_raw]
                    summary = generate_summary(topic, findings_raw, source_info, user_prefs).result()
                    human_feedbacks.append(HumanFeedback(
                        feedback_type="fact_correction",
                        content=f"{corrections_count} facts corrected",
                        round_number=round_num,
                    ))
                    feedback_rounds += 1
                    print(f"   ✅ {corrections_count} facts corrected. Summary regenerated.")
                else:
                    print(f"   No corrections. Moving on.")
        else:
            print(f"\n   ⚠️ Max rounds reached ({MAX_FEEDBACK_ROUNDS}). Using the latest version.")

        # --- PHASE 7: Build final report ---
        avg_rel = sum(r["relevance"] for r in unique) / len(unique) if unique else 0.5
        base_conf = calculate_confidence(len(unique), avg_rel, len(findings)).result()
        avail_factor = total_ok / (total_ok + total_failed) if (total_ok + total_failed) > 0 else 0.5
        confidence = round(base_conf * (0.7 + 0.3 * avail_factor), 2)

        sources = [Source(name=r["source_name"], source_type=r["source_type"], content=r["content"]) for r in unique]
        facts_corrected = sum(1 for fb in human_feedbacks if fb.feedback_type == "fact_correction")

        report = ResearchReport(
            topic=topic, summary=summary, key_findings=findings, sources=sources,
            sub_queries=[sq.query for sq in sub_queries], confidence=confidence,
            source_availability=source_statuses, version="v4",
            user_id=user_id, session_number=profile["total_sessions"] + 1,
            human_feedback_rounds=feedback_rounds,
            plan_was_edited=was_edited,
            facts_corrected=facts_corrected,
        )

        record_session(store, user_id, topic, summary[:200])

        pipeline_ms = (time.time() - pipeline_start) * 1000
        print(f"\n📄 v4 report generated. Session #{report.session_number}.")
        print(f"   Feedback rounds: {feedback_rounds} | Facts corrected: {facts_corrected}")
        print(f"   Plan edited: {'yes' if was_edited else 'no'} | Time: {pipeline_ms:.0f}ms")
        print(f"{'=' * 60}")

        return report.model_dump()

    return research_agent

Step 5: An interactive CLI with HITL flows (main.py)

The CLI runs the whole conversation: it receives the agent's interrupts, presents them to the user in readable form, collects the answer, and resumes execution.

"""
main.py
CLI for the AI Research Assistant v4. Interactive human-in-the-loop.
"""

import sys
import json
import uuid

sys.path.insert(0, ".")

from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import Command
from memory.user_store import create_store, persist_store, get_user_profile, get_preferences, save_preference
from agents.researcher import create_research_agent
from config.settings import FREE_SOURCES, PAID_SOURCES


def handle_interrupt(state_snapshot) -> Command:
    """Processes an interrupt and returns the Command to respond with."""
    task_data = state_snapshot.tasks
    if not task_data:
        return Command(resume={"action": "approve"})

    for task_item in task_data:
        if hasattr(task_item, "interrupts") and task_item.interrupts:
            interrupt_data = task_item.interrupts[0].value
            interrupt_type = interrupt_data.get("type", "unknown")

            print(f"\n{'─' * 58}")
            print(f"  🔔 INTERRUPT: {interrupt_type}")
            print(f"{'─' * 58}")
            print(f"  {interrupt_data.get('message', 'No message')}")
            print(f"{'─' * 58}")

            if interrupt_type == "plan_approval":
                return handle_plan_approval(interrupt_data)
            elif interrupt_type == "cost_approval":
                return handle_cost_approval(interrupt_data)
            elif interrupt_type == "draft_feedback":
                return handle_draft_feedback(interrupt_data)
            elif interrupt_type == "fact_correction":
                return handle_fact_correction(interrupt_data)
            else:
                return Command(resume={"action": "approve"})

    return Command(resume={"action": "approve"})


def handle_plan_approval(data: dict) -> Command:
    """Handles approval of the research plan."""
    plan = data.get("plan", {})
    print(f"\n  Free sources: {plan.get('free_sources', [])}")
    paid = plan.get("paid_sources", [])
    if paid:
        print(f"  Paid sources: {[s['source'] for s in paid]} (${plan.get('total_paid_cost', 0):.2f})")

    while True:
        try:
            choice = input("\n  [approve/edit/cancel] → ").strip().lower()
        except (KeyboardInterrupt, EOFError):
            return Command(resume={"action": "cancel"})

        if choice in ("approve", "a", "ok", "okay", "yes", "y"):
            return Command(resume={"action": "approve"})
        elif choice in ("cancel", "c", "no", "n"):
            return Command(resume={"action": "cancel"})
        elif choice.startswith("edit") or choice == "e":
            all_sources = list(FREE_SOURCES) + list(PAID_SOURCES.keys())
            print(f"  Available sources: {all_sources}")
            try:
                sources_input = input("  Type the sources separated by commas: ").strip()
            except (KeyboardInterrupt, EOFError):
                return Command(resume={"action": "cancel"})
            edited = [s.strip() for s in sources_input.split(",") if s.strip()]
            return Command(resume={"action": "edit", "sources": edited})
        else:
            print(f"  Unrecognized option: '{choice}'")


def handle_cost_approval(data: dict) -> Command:
    """Handles approval of one paid source."""
    source = data.get("source", "?")
    cost = data.get("cost", 0)
    print(f"\n  Source: {source} | Cost: ${cost:.2f}")

    while True:
        try:
            choice = input(f"  Approve ${cost:.2f}? [yes/no] → ").strip().lower()
        except (KeyboardInterrupt, EOFError):
            return Command(resume={"approved": False})

        if choice in ("yes", "yeah", "yep", "y", "ok"):
            return Command(resume={"approved": True})
        elif choice in ("no", "n"):
            return Command(resume={"approved": False})
        else:
            print(f"  Answer 'yes' or 'no'.")


def handle_draft_feedback(data: dict) -> Command:
    """Handles feedback on the report draft."""
    round_num = data.get("round", 1)
    print(f"\n  Round {round_num} — options:")
    print(f"    approve       → Approve the current draft")
    print(f"    feedback <tx> → Give feedback to improve it")
    print(f"    edit_facts    → Correct factual data")

    while True:
        try:
            choice = input(f"\n  → ").strip()
        except (KeyboardInterrupt, EOFError):
            return Command(resume={"action": "approve"})

        if choice.lower() in ("approve", "a", "ok"):
            return Command(resume={"action": "approve"})
        elif choice.lower().startswith("feedback ") or choice.lower().startswith("fb "):
            text = choice.split(" ", 1)[1] if " " in choice else ""
            if not text:
                try:
                    text = input("  Your feedback: ").strip()
                except (KeyboardInterrupt, EOFError):
                    return Command(resume={"action": "approve"})
            return Command(resume={"action": "feedback", "text": text})
        elif choice.lower() in ("edit_facts", "edit", "ef"):
            return Command(resume={"action": "edit_facts"})
        else:
            print(f"  Unrecognized option. Use: approve, feedback <text>, edit_facts")


def handle_fact_correction(data: dict) -> Command:
    """Handles fact corrections."""
    findings = data.get("findings", [])
    print(f"\n  Current findings:")
    for i, f in enumerate(findings):
        print(f"    [{i}] {f.get('title', '?')}: {f.get('description', '?')[:70]}...")

    print(f"\n  To correct one, type: <index> <new text>")
    print(f"  Type 'done' when you're finished.")

    corrections = []
    while True:
        try:
            line = input("  correction → ").strip()
        except (KeyboardInterrupt, EOFError):
            break

        if line.lower() in ("done", "d", "finish", ""):
            break

        parts = line.split(" ", 1)
        if len(parts) == 2 and parts[0].isdigit():
            idx = int(parts[0])
            if 0 <= idx < len(findings):
                corrections.append({"index": idx, "field": "description", "new_value": parts[1]})
                print(f"    ✅ Finding [{idx}] marked for correction.")
            else:
                print(f"    ⚠️ Index {idx} out of range (0-{len(findings)-1})")
        else:
            print(f"    Format: <index> <new text>")

    return Command(resume={"corrections": corrections})


def format_report(report: dict) -> str:
    if "error" in report:
        return f"\n  ❌ {report['error']}"

    lines = [
        "", "╔" + "═" * 58 + "╗",
        "║" + f"  📄 v4 REPORT | {report.get('user_id')} | Session #{report.get('session_number')}".center(58) + "║",
        "╚" + "═" * 58 + "╝",
        f"\n📌 Topic: {report['topic']}",
        f"🎯 Confidence: {report['confidence']:.0%}",
    ]

    hitl_info = []
    if report.get("plan_was_edited"):
        hitl_info.append("Plan edited by the user")
    if report.get("human_feedback_rounds", 0) > 0:
        hitl_info.append(f"{report['human_feedback_rounds']} feedback rounds")
    if report.get("facts_corrected", 0) > 0:
        hitl_info.append(f"{report['facts_corrected']} facts corrected")
    if hitl_info:
        lines.append(f"👤 Supervision: {' | '.join(hitl_info)}")

    lines.extend([f"\n{'─' * 60}", "📋 SUMMARY", f"{'─' * 60}", report["summary"]])
    lines.extend([f"\n{'─' * 60}", "💡 FINDINGS", f"{'─' * 60}"])
    for i, f in enumerate(report["key_findings"], 1):
        lines.append(f"  {i}. {f['title']} [{f['confidence']:.0%}]")
        lines.append(f"     {f['description']}")

    lines.extend([f"\n{'─' * 60}", f"📚 SOURCES ({len(report['sources'])})", f"{'─' * 60}"])
    for s in report["sources"]:
        lines.append(f"  • [{s['source_type'].upper()}] {s['name']}")

    if report.get("source_availability"):
        lines.extend([f"\n{'─' * 60}", "🔌 AVAILABILITY", f"{'─' * 60}"])
        for sa in report["source_availability"]:
            icon = "✅" if sa["status"] == "ok" else "❌"
            retry = f" ({sa['attempts']} attempts)" if sa["attempts"] > 1 else ""
            lines.append(f"  {icon} {sa['source_type']}: {sa['status']}{retry}")

    lines.append(f"\n{'═' * 60}")
    return "\n".join(lines)


def run_research_with_hitl(agent, topic: str, user_id: str, store):
    """Runs a full research pass, handling every interrupt along the way."""
    thread_id = f"v4-{user_id}-{uuid.uuid4().hex[:8]}"
    config = {"configurable": {"thread_id": thread_id, "user_id": user_id}}

    result = agent.invoke(topic, config)

    while True:
        state = agent.get_state(config)
        if not state.next:
            break

        command = handle_interrupt(state)
        result = agent.invoke(command, config)

    return result


def run_interactive():
    print("=" * 60)
    print("  🔬 AI Research Assistant v4 — Human-in-the-Loop")
    print("=" * 60)

    store = create_store()
    checkpointer = MemorySaver()
    agent = create_research_agent(checkpointer, store)
    current_user = None

    print("\n  Commands: user <name> | profile | pref <k> <v> | exit\n")

    while True:
        if not current_user:
            try:
                current_user = input("👤 User: ").strip()
            except (KeyboardInterrupt, EOFError):
                break
            if not current_user:
                continue
            profile = get_user_profile(store, current_user)
            if profile["is_new_user"]:
                print(f"  Welcome, {current_user}! First time here.\n")
            else:
                print(f"  Welcome back, {current_user}! ({profile['total_sessions']} sessions)")
                if profile["last_session"]:
                    print(f"  Last: '{profile['last_session']['topic']}' ({profile['last_session']['timestamp'][:10]})\n")
            continue

        try:
            user_input = input(f"🔎 [{current_user}] ").strip()
        except (KeyboardInterrupt, EOFError):
            break

        if not user_input:
            continue
        if user_input.lower() in ("exit", "quit", "bye"):
            break

        if user_input.lower().startswith("user "):
            current_user = user_input[5:].strip()
            profile = get_user_profile(store, current_user)
            status = "new" if profile["is_new_user"] else f"{profile['total_sessions']} sessions"
            print(f"  Switched to {current_user} ({status})\n")
            continue

        if user_input.lower() == "profile":
            profile = get_user_profile(store, current_user)
            prefs = get_preferences(store, current_user)
            print(f"\n  Sessions: {profile['total_sessions']}")
            for t, c in profile["top_topics"]:
                print(f"    - {t} ({c}x)")
            print(f"  Prefs: {json.dumps(prefs, ensure_ascii=False)}\n")
            continue

        if user_input.lower().startswith("pref "):
            parts = user_input.split(maxsplit=2)
            if len(parts) == 3:
                save_preference(store, current_user, parts[1], parts[2], "Manual")
                print(f"  ✓ {parts[1]} = {parts[2]}\n")
            continue

        try:
            report = run_research_with_hitl(agent, user_input, current_user, store)
            print(format_report(report))
        except Exception as e:
            print(f"\n  ❌ Error: {e}\n")

    print("\n  Saving memory...")
    persist_store(store)
    print("  See you next time!")


if __name__ == "__main__":
    run_interactive()

Running it: the full supervised experience

A typical session with all 4 HITL types

cd research-assistant
python main.py
============================================================
  🔬 AI Research Assistant v4 — Human-in-the-Loop
============================================================

  [Memory] Loaded 5 items from memory_store.json

👤 User: mike
  Welcome back, mike! (3 sessions)
  Last: 'RAG techniques' (2026-03-07)

🔎 [mike] AI agents frameworks comparison

============================================================
  🔬 AI Research Assistant v4 — Supervised
  Welcome back! Last research: 'RAG techniques' (2026-03-07). You're at 3 sessions.
  Topic: AI agents frameworks comparison | User: mike | Request: a1b2c3d4
============================================================

📋 Decomposing the topic...
   1. What are the main AI agent frameworks available?
   2. How do LangGraph, CrewAI, and AutoGen compare?
   3. What are the production considerations for each?
   4. What are real-world use cases for each framework?

──────────────────────────────────────────────────────
  🔔 INTERRUPT: plan_approval
──────────────────────────────────────────────────────
  Research plan for 'AI agents frameworks comparison':
    Sub-queries: 4
    Free sources: [web, academic, news]
    Paid sources: [premium_research] ($2.50)
  Shall I proceed? Options: approve / edit / cancel
──────────────────────────────────────────────────────

  Free sources: ['web', 'academic', 'news']
  Paid sources: ['premium_research'] ($2.50)

  [approve/edit/cancel] → edit
  Available sources: ['web', 'academic', 'news', 'premium_research', 'patent_db', 'financial_data']
  Type the sources separated by commas: web, academic

   Plan edited: ['web', 'academic']

   No paid sources in the plan.

🔍 Searching 2 sources: ['web', 'academic']
   Sub-query 1: 2/2 OK
   Sub-query 2: 2/2 OK
   Sub-query 3: 1/2 OK
   Sub-query 4: 2/2 OK

🔀 16 raw → 7 unique
🧠 4 findings identified

📝 Showing the draft (round 1)...

──────────────────────────────────────────────────────
  🔔 INTERRUPT: draft_feedback
──────────────────────────────────────────────────────
  --- Report draft (round 1/3) ---

  Topic: AI agents frameworks comparison

  Summary:
  The main frameworks for AI agents include LangGraph,
  CrewAI, and AutoGen. LangGraph stands out for its granular control...

  Findings:
    1. LangGraph offers granular control [85%]: It lets you define...
    2. CrewAI simplifies multi-agent [80%]: A role-oriented framework...
    3. AutoGen leads on conversation [75%]: Designed by Microsoft...
    4. Production favors LangGraph [82%]: In production environments...

  Any feedback? Options: approve / feedback <text> / edit_facts
──────────────────────────────────────────────────────

  Round 1 — options:
    approve       → Approve the current draft
    feedback <tx> → Give feedback to improve it
    edit_facts    → Correct factual data

  → feedback Add a more direct comparison between the three. Mention that CrewAI is newer and has a smaller community.

   📝 Feedback received: 'Add a more direct comparison between the three...'
   🔄 Summary updated.

📝 Showing the draft (round 2)...

──────────────────────────────────────────────────────
  🔔 INTERRUPT: draft_feedback
──────────────────────────────────────────────────────
  (improved draft with the direct comparison)

  → edit_facts

   ✏️ Fact editing mode...

──────────────────────────────────────────────────────
  🔔 INTERRUPT: fact_correction
──────────────────────────────────────────────────────

  Current findings:
    [0] LangGraph offers granular control: It lets you define flows...
    [1] CrewAI simplifies multi-agent: A role-oriented framework...
    [2] AutoGen leads on conversation: Designed by Microsoft in 2023...
    [3] Production favors LangGraph: In production environments...

  To correct one, type: <index> <new text>
  Type 'done' when you're finished.
  correction → 2 Designed by Microsoft Research, with version 0.4 released in 2025. It supports conversational agent patterns and extensible tools.
    ✅ Finding [2] marked for correction.
  correction → done

   ✅ 1 facts corrected. Summary regenerated.

📝 Showing the draft (round 3)...

  → approve

   ✅ Draft approved on round 3.

📄 v4 report generated. Session #4.
   Feedback rounds: 2 | Facts corrected: 1
   Plan edited: yes | Time: 45230ms
============================================================

╔══════════════════════════════════════════════════════════╗
║  📄 v4 REPORT | mike | Session #4                        ║
╚══════════════════════════════════════════════════════════╝

📌 Topic: AI agents frameworks comparison
🎯 Confidence: 78%
👤 Supervision: Plan edited by the user | 2 feedback rounds | 1 facts corrected
...

The full session hits all 4 HITL types:

  • Plan approval → the user edited the sources (dropped news, dropped premium_research)
  • Cost gate → never fired, because the edited plan has no paid sources
  • Draft feedback → the user gave textual feedback that improved the summary
  • Fact correction → the user fixed an out-of-date fact about AutoGen

Success criteria

  • Plan approval works — the agent shows the plan, and the user can approve, edit the sources, or cancel
  • The cost gate works — free sources auto-approve, paid sources interrupt with a cost estimate
  • The feedback loop improves quality — the user gives textual feedback, the agent regenerates the summary incorporating it
  • State editing fixes factual errors — the user edits findings directly, and the summary is regenerated with the corrected data
  • The CLI handles every flow — the interaction feels natural, with clear prompts and understandable options
  • Supervision metadata lands in the report — the report includes human_feedback_rounds, plan_was_edited, facts_corrected
  • Memory persists across sessions — the v3 data (preferences, history) still works

Test scenarios

Test 1: Plan approved with no changes

🔎 [mike] quantum computing trends
  → Plan: [web, academic, news] + [premium_research $2.50]
  → approve
  → Cost gate: premium_research $2.50 → no
  → Draft → approve
Result: a report with 3 free sources, no feedback rounds, plan not edited.

Test 2: Plan edited + paid source approved

🔎 [mike] patent analysis for AI chips
  → Plan: [web, academic, news] + [premium_research, patent_db]
  → edit → web, academic, patent_db
  → Cost gate: patent_db $1.00 → yes
  → Draft → approve
Result: a report with academic + web + patent_db. Plan edited. Cost: $1.00.

Test 3: Research cancelled

🔎 [mike] something I changed my mind about
  → Plan: [web, academic, news]
  → cancel
Result: {"error": "Research cancelled", "version": "v4"}

Test 4: A full feedback loop (3 rounds)

🔎 [mike] deep learning optimization techniques
  → approve plan
  → Draft round 1 → feedback "More focus on hardware-aware optimization"
  → Draft round 2 → feedback "Add a mention of quantization"
  → Draft round 3 → approve
Result: a report improved by 2 feedback rounds. Approved on the 3rd round.

Test 5: Factual correction

🔎 [mike] LLM benchmarks 2025
  → approve plan
  → Draft → edit_facts
  → Finding [1] says "GPT-4 leads on MMLU" → correct it to "Claude 3.5 Sonnet has led on MMLU since Oct 2024"
  → done → approve
Result: a report with 1 corrected fact reflected in the final summary.

Test 6: Multiple paid sources — partial approval

🔎 [mike] competitive analysis SaaS pricing
  → Plan includes [premium_research $2.50, financial_data $5.00]
  → approve plan
  → Cost gate: premium_research $2.50 → yes
  → Cost gate: financial_data $5.00 → no
Result: the report uses premium_research but not financial_data. Cost: $2.50 instead of $7.50.

Common errors

1. interrupt() doesn't pause — the agent runs straight through

Cause: The graph has no checkpointer configured. Without a checkpointer, interrupt() can't save the state and simply doesn't work.

Fix: Check that @entrypoint(checkpointer=checkpointer) is configured and that checkpointer isn't None:

checkpointer = MemorySaver()
agent = create_research_agent(checkpointer, store)

2. Command(resume=...) doesn't resume — the agent starts from scratch

Cause: The thread_id in the resume config doesn't match the one from the original invocation.

Fix: Use exactly the same config to resume:

config = {"configurable": {"thread_id": "v4-mike-abc123", "user_id": "mike"}}
result = agent.invoke(topic, config)
# ... get the interrupt ...
result = agent.invoke(Command(resume=response), config)  # same config

3. The feedback loop never ends — infinite cycle

Cause: The loop's exit condition doesn't cover every case, or the action in the response doesn't match the expected values.

Fix: Always include an else or a default break, and cap the rounds:

for round_num in range(1, MAX_FEEDBACK_ROUNDS + 1):
    response = collect_feedback(...).result()
    if response.get("action") == "approve":
        break
    # ... handle feedback ...
else:
    pass  # max rounds reached, use the latest version

4. Fact corrections don't show up in the summary

Cause: After correcting the findings, you never regenerate the summary with the new data.

Fix: Always regenerate the summary after editing findings:

corrected = collect_fact_corrections(findings_raw).result()
if corrections_count > 0:
    findings_raw = corrected
    summary = generate_summary(topic, findings_raw, ...).result()  # regenerate

5. state.tasks is empty when you try to read the interrupt

Cause: You're reading the state before the agent has reached the interrupt point, or the interrupt was already resolved.

Fix: Read the state only when state.next says the agent is paused:

state = agent.get_state(config)
if state.next:  # a node is pending → there's an interrupt
    for task in state.tasks:
        if hasattr(task, "interrupts") and task.interrupts:
            data = task.interrupts[0].value

6. The CLI can't tell interrupt types apart

Cause: Every interrupt gets handled the same way, without inspecting the type in the payload.

Fix: Each interrupt sends a type in its payload. Use a dispatcher:

interrupt_type = data.get("type", "unknown")
if interrupt_type == "plan_approval":
    return handle_plan_approval(data)
elif interrupt_type == "cost_approval":
    return handle_cost_approval(data)

7. The v3 memory stops working after adding HITL

Cause: The store or the checkpointer isn't passed correctly to the create_research_agent() factory.

Fix: Check that both are created and passed in:

store = create_store()
checkpointer = MemorySaver()
agent = create_research_agent(checkpointer, store)  # both arguments

8. The paid-source costs don't match reality

Cause: SOURCE_COSTS in risk_assessment.py doesn't reflect the APIs' current prices.

Fix: Keep a single source of truth for the costs:

# config/settings.py — the source of truth
PAID_SOURCES = {"premium_research": 2.50, "patent_db": 1.00}

# hitl/risk_assessment.py — imports from config
from config.settings import PAID_SOURCES
SOURCE_COSTS = {**{s: 0.0 for s in FREE_SOURCES}, **PAID_SOURCES}

What's coming: Module 10 — Multi-Agent Systems

Your Research Assistant v4 is a supervised agent: it shows plans, asks approval for expensive actions, accepts iterative feedback, and lets you correct factual data. It's the complete human-in-the-loop experience with a single agent.

But it's a single agent doing everything: planning, searching, synthesizing, writing. What happens when the task is too big or too specialized for one? What happens when you need one agent to search, another to analyze, and a third to write — each with its own tools and model?

Module 10 scales from one supervised agent to multiple coordinated agents. One plans, another runs searches, another synthesizes. The human supervises the "manager" that coordinates the rest. The same HITL techniques you learned — approval, feedback, state editing — now apply at the team level: which agent needs supervision? Which one can run autonomously? How do you coordinate approvals when 5 agents are working in parallel?


Project resources

  1. LangGraph Human-in-the-Loop — Official HITL concepts
  2. interrupt() API Reference — Reference for the interrupt function
  3. LangGraph Command — Using Command to resume
  4. How to edit graph state — Editing state during execution
  5. LangGraph Functional API + HITL — Interrupt inside @entrypoint and @task
  6. How to review tool calls — The tool review pattern

Module 9 — LangChain & LangGraph: From Chains to Agents