Testing Agents That Rely on Human Interaction (Interrupts + HITL)

Hey LangChain community! I’d love to get your thoughts on building evals for agents that rely heavily on human-in-the-loop interactions. Specifically, how do you approach evaluating agents that use interrupts and require human approval for critical actions? I’d be interested to hear about your experience, the challenges you’ve encountered, and how you’ve overcome them.

Great question — and the good news is that interrupts make HITL agents more testable than they look, because the human decision becomes a scripted input rather than a live person. The key shift: you don’t mock the human; you resume the paused run with a programmatic decision.

The core pattern. With the HumanInTheLoopMiddleware, the agent pauses on configured tools and returns an interrupt instead of executing. In a test you (1) run until it pauses, (2) assert it paused on the right action, (3) resume with a scripted decision, (4) assert the downstream trajectory. That covers the whole loop deterministically:

from langgraph.types import Command
config = {"configurable": {"thread_id": "eval-1"}}  # checkpointer required to pause/resume

# 1. Run until it pauses at the approval gate
result = agent.invoke({"messages": [{"role": "user", "content": "Delete the prod table"}]}, config)

# 2. Assert it interrupted on the right action (didn't silently execute)
assert result["__interrupt__"]
action = result["__interrupt__"][0].value["action_requests"][0]
assert action["name"] == "delete_table"

# 3. Resume with a *scripted* human decision — this is your "human"
result = agent.invoke(
    Command(resume={"decisions": [{"type": "reject", "message": "Not allowed in prod"}]}),
    config,
)

# 4. Assert the downstream trajectory: tool NOT executed, agent recovered gracefully
assert not any(getattr(m, "name", None) == "delete_table" for m in result["messages"] if m.type == "tool")

What’s actually worth evaluating (each maps to a test case):

  • Gate precision/recall — does it interrupt on every critical action, and not over-interrupt safe ones? Drive it with prompts designed to trigger (and not trigger) each gated tool.
  • All four decision typesapprove / edit / reject / respond each have distinct correct behavior: approve executes as-is, edit runs with modified args, reject skips + feeds back, respond returns the message as the tool result. Test each branch’s downstream effect.
  • Post-resume trajectory — after rejection, does the agent recover sensibly (not blindly retry the same call)? This is where an LLM-as-judge shines.

Scaling beyond unit assertions:

  • Trajectory evals — score the full message history (including the HITL turns) with create_trajectory_llm_as_judge against a reference trajectory.
  • Simulated humans — for “ask the user” (respond) style tools where the human is a conversational participant, use multi-turn simulation with an LLM-simulated user to auto-generate the human side.
  • pytest integration ties it all into CI and logs results to LangSmith.

Gotchas people hit:

  • Non-determinism makes assertions flaky. Record/replay the LLM calls with vcrpy cassettes so CI is stable and free, and lean on LLM-as-judge where exact-match is too brittle.
  • Interrupt replay. A node re-runs from the top on every resume — avoid while True + interrupt() loops or you get exponential re-execution. Call interrupt() once per node.
  • Slow setup. If reaching the approval gate takes many steps, use a checkpointer + partial execution to jump straight to the paused state instead of replaying the whole run each test.
  • edit decisions: keep arg edits conservative in tests — large changes can make the model re-plan and take unexpected paths.

Happy to share a fuller pytest example if useful. What does your human step look like — approval gates on tool calls, or more of a back-and-forth ask_user flow? That changes which eval approach fits best.

Hey @dariel.datoon, thanks for the detailed reply—that was really insightful.

For my use case, I have a hybrid setup consisting of approval gates (for some critical tools) and non-deterministic interrupts, where the agent asks the user one or more clarification questions. The user can either choose from up to three pregenerated options or provide their own response.

The approach makes sense now: whenever the interaction is deterministic, I’ll programmatically hardcode the replies. For non-deterministic flows, I’ll rely on an LLM to mock the user feedback.