LangChain
cli_sdk.integrations.langchain puts a calibrated check in front of the
tools of a LangChain 1.x agent built with create_agent. You pass
middleware=cli_middleware(guard), and every call the model proposes to a
guarded tool is allowed, sent to a human through LangChain’s own
HumanInTheLoopMiddleware, or refused with an error ToolMessage. Each
decision carries a finite-sample guarantee on data like your calibration
set, and anything the calibration cannot vouch for goes to a person. Tools
without a rule, such as read-only lookups, run as usual.
Why calibrate the threshold
The usual guardrail is a confidence threshold picked by hand (“auto-approve refunds above 0.8”). Nobody can say how many auto-approved refunds that number lets through wrong, and its meaning changes silently when the model, the prompt or the traffic changes.
The refunds example below makes this concrete. On its 320 labelled (synthetic) refund decisions, a hand-set threshold of 0.80 would auto-approve 183 requests, 20 of them wrong. The conformal risk control bound for that threshold is (20 + 1) / 321 = 0.065, above the 0.05 target, and nothing in the code would show it. The calibrated threshold is 0.863: the lowest threshold whose bound is at most 0.05 (178 approved, 15 wrong, bound 0.0498). It comes with the statement “Expected rate of decisions that are auto-approved and wrong is at most 0.05”. It is recomputed whenever you recalibrate, and it is tied to the evidence model and prompt it was computed with, so a change makes the profile stale and every guarded call goes to a human until you recalibrate.
A guarantee bounds a rate over many decisions on data exchangeable with the calibration set. It never makes a single refund or a single triage reply correct. Read “at most 5% of requests like these are auto-approved and wrong”, never “this approval is safe”. See Guarantees.
Install
pip install "cci-sdk[langchain]" # mock mode: offline, keyless
pip install "cci-sdk[langchain,openai]" langchain-openai # OpenAI, Azure OpenAI, Gemma on vLLM or SGLang
pip install "cci-sdk[langchain,anthropic]" langchain-anthropic # Claude as agent and evidence model
pip install "cci-sdk[langchain,openai]" langchain-google-genai # Gemini (the Gemini evidence backend uses the openai SDK)The langchain extra installs langchain, langchain-core and langgraph
(1.x). The provider packages (langchain-openai, langchain-anthropic,
langchain-google-genai) are for the agent model. The openai and
anthropic extras are for the evidence model. The framework packages need
Python 3.10 or later.
How decisions map onto LangChain
cli_middleware(guard) returns two middleware for create_agent:
HumanInTheLoopMiddleware, configured with the guard, and
CLIGuardMiddleware. Across one model turn they run in this order:
- Score once.
CLIGuardMiddleware.after_modelscores every guarded call in the newAIMessagewithguard.check(...)and stores each decision in the agent state undercli_guard, keyed by tool call id. The state is checkpointed, so the decision survives the pause. - Pause if escalated.
HumanInTheLoopMiddleware.after_modelruns next. Itswhenpredicate reads the stored decision, so only escalated calls interrupt. All escalated calls from one turn go into one interrupt. - Enforce at execution.
CLIGuardMiddleware.wrap_tool_callrefuses blocked calls, and in batch mode escalated ones, before the tool runs.
| Guard decision | cli_middleware(guard) (human review on) | cli_middleware(guard, human_review=False) (batch jobs) |
|---|---|---|
allow | No interrupt. The tool runs. | The tool runs. |
escalate | The run pauses on a HumanInTheLoopMiddleware interrupt. pending_reviews(result) lists the actions with a review card. Resume with Command(resume={"decisions": [...]}): approve runs the call. edit runs the reviewer’s arguments as written. reject returns an error ToolMessage (User rejected the tool call ...) and the tool does not run. | The tool does not run. The agent gets an error ToolMessage with "status": "needs_human_review" and the decision under "cli", which you can queue. |
block | Never interrupts. The tool does not run. The agent gets an error ToolMessage with "status": "blocked". | Same. |
A local-mode Gate either auto-approves or escalates. block comes from
Set or Interval rules with block_labels or block_levels, from a
hosted Gate that abstains, and from on_heuristic="block". An uncalibrated
(heuristic) answer never allows.
Set up a guarded agent
Choose the agent model
The agent model proposes tool calls. The examples build it with
chat_model(provider) in
examples/agents/_shared/langchain_models.py,
which reads the model, key and endpoint from environment variables (see
examples/agents/.env.example). While a key still holds its
YOUR_..._API_KEY placeholder, the example stops with a clear message
before any network call. Every code block below is the branch of
chat_model for that provider; s holds the settings from the
environment.
export OPENAI_API_KEY=... # your key
export OPENAI_MODEL=gpt-4.1-mini # optional; this is the default
python examples/agents/langchain/finance_refund_agent.py --provider openaiif provider == "openai":
from langchain_openai import ChatOpenAI
return ChatOpenAI(model=s.model, api_key=s.api_key, base_url=s.base_url, temperature=0,
use_responses_api=False, **overrides)Choose the evidence model and calibrate
The evidence model scores each proposed guarded call. It is the model the
calibration describes, and it can differ from the agent model
(--evidence-provider). There are two access levels in local mode:
| Access level | How a score is made | Providers | Requests to calibrate 320 examples | Requests per guarded call |
|---|---|---|---|---|
| L1, log-probabilities | One request; the first-token probabilities of the option letters | OpenAI and Azure OpenAI non-reasoning models (for example gpt-4.1-mini), Gemma on vLLM or SGLang | 320 | 1 |
| L0, sampling | sample_count sampled answers, turned into smoothed frequencies | Anthropic Claude, Gemini (the examples use sample_count=8) | 320 x 8 = 2,560 | 8 |
At L0 a score can take at most sample_count + 1 distinct values, so the
calibrated threshold is coarser and more calls escalate than with an L1
model. Calibration runs once per evidence model. The profile is a JSON file
in the store directory, fingerprinted with the evidence model, its settings
and the query’s prompt. A mismatch makes the guard fail closed: every
guarded call escalates until you recalibrate. The examples detect a stale
profile and rebuild it automatically; --recalibrate forces a rebuild. See
Bring your own model for every backend and
Local mode for profile files and sizes.
The guarantee holds only for the scoring function it was calibrated with.
The guard must therefore see exactly the context the calibration examples
were built with. Both come from one function, refund_context in
data/generate_refund_requests.py:
def refund_context(order: dict[str, Any], amount: float, reason: str) -> dict[str, Any]:
"""The evaluation context for one proposed refund: calibration and inference both use this."""
return {
"policy": REFUND_POLICY,
"order": dict(order),
"proposed_refund": {"amount": round(float(amount), 2), "reason": reason},
}The example defines the query, the guard’s context builder, and the
calibration step (from finance_refund_agent.py):
REFUND_GATE = Gate(
instructions=(
"Under the refund policy in the context, is issuing the proposed refund for this order correct? "
"Answer true only if every rule of the policy holds for the order record and the proposed refund."
),
calibration_profile="refund-approvals-v1",
guarantee="risk",
target=0.05,
)def refund_guard_context(args: dict[str, Any]) -> dict[str, Any]:
"""What the guard evaluates for a proposed issue_refund call.
The order comes from the order system, not from the agent, and the
context is built by the same function as every calibration example.
An unknown order raises, and the guard then escalates (fails closed).
"""
order = ORDERS.get(args["order_id"])
if order is None:
raise LookupError(f"no order {args['order_id']!r} in the order system")
return refunds.refund_context(order, args["amount"], args["reason"])client = LocalCLIClient(evidence, store=calibration.default_store(__file__, args.store),
sample_count=providers.sample_count(evidence_provider))
cached, _ = client.calibration_status(REFUND_GATE)
examples = calibration.load_jsonl("refund_requests.jsonl")
try:
(profile,) = calibration.ensure_calibrated(client, [REFUND_GATE], examples, recalibrate=args.recalibrate)
except BackendError as exc: # e.g. the vLLM / SGLang server is not running
raise SystemExit(f"could not score the calibration set with --evidence-provider {evidence_provider}: "
f"{exc}") from excHere evidence = providers.evidence_backend(evidence_provider, mock_scorer=refund_scorer).
With --provider mock that is a MockEvidenceBackend driven by a
deterministic scoring function that reads the order the way a fallible
model would. It never sees a label.
Wire the guard
One GuardRule per risky tool, and the guard as middleware. Interrupts
need a checkpointer and a thread_id:
def build_guard(client: LocalCLIClient) -> ToolGuard:
return ToolGuard(client, [GuardRule(tool="issue_refund", query=REFUND_GATE, context=refund_guard_context)])def build_agent(model: Any, guard: ToolGuard, ledger: list[dict[str, Any]], *, human_review: bool = True,
checkpointer: Any = None) -> Any:
"""``create_agent`` with the calibrated guard as middleware.
Interrupts need a checkpointer. ``InMemorySaver`` is for a single
process; in production use a durable checkpointer shared by every worker
(the guard's decisions are stored in the checkpointed state, so a resume
on another worker honours the decision the reviewer saw).
"""
return create_agent(
model,
tools=make_tools(ledger),
system_prompt=SYSTEM_PROMPT,
middleware=cli_middleware(guard, human_review=human_review),
checkpointer=checkpointer if checkpointer is not None else InMemorySaver(),
)lookup_order has no rule, so it always runs. issue_refund is checked
before every execution.
Handle reviews and resume
Invoke with a thread_id. While pending_reviews(result) is non-empty,
collect one decision per pending action, in order, and resume on the same
thread with Command(resume={"decisions": [...]}). The guard’s decisions
are in the checkpointed state; guard_decisions(result) returns them keyed
by tool call id. From finance_refund_agent.py:
def run_scenario(agent: Any, scenario: Scenario, reviewer: Reviewer, edits: dict[str, dict[str, Any]],
ledger: list[dict[str, Any]], thread: str) -> Outcome:
outcome = Outcome(scenario.case_id)
already = len(ledger)
config = {"configurable": {"thread_id": thread}}
result = agent.invoke({"messages": [HumanMessage(scenario.message)]}, config, version="v2")
shown = show_messages(result, 0, outcome)
for _ in range(5): # a real agent may propose more than one guarded call
pending = pending_reviews(result)
if not pending:
break
print(" paused: HumanInTheLoopMiddleware interrupt; the run waits for a reviewer")
decisions = [review(scenario, action, stored_decision_for(result, action), reviewer, edits, outcome)
for action in pending]
_wrap(" resume: ", f"Command(resume={json.dumps({'decisions': decisions})})")
if any(d["type"] == "edit" for d in decisions):
_wrap(" note: ", "a reviewer's edit is an explicit human approval: the edited call runs as "
"written and is not scored again")
result = agent.invoke(Command(resume={"decisions": decisions}), config, version="v2")
shown = show_messages(result, shown, outcome)
outcome.refunds_issued = ledger[already:]
return outcomereview returns one of the three HumanInTheLoopMiddleware decision
shapes that cli_middleware allows by default (approve, edit, reject). The
reviewer sees the decision stored for the call: action, reason and
guarantee statement:
def review(scenario: Scenario, action: dict[str, Any], decision: dict[str, Any], reviewer: Reviewer,
edits: dict[str, dict[str, Any]], outcome: Outcome) -> dict[str, Any]:
"""Turn a pending action into a HumanInTheLoopMiddleware decision (approve / edit / reject)."""
note = REVIEW_NOTES.get(scenario.case_id, "Declined by the reviewer.")
if not reviewer.interactive and scenario.case_id in edits:
print(f" review request for {scenario.case_id}:")
for line in format_request(decision).splitlines():
print(f" {line}")
new_args = {**action["args"], **edits[scenario.case_id]}
changes = ", ".join(f"{k} {action['args'].get(k)} -> {v}" for k, v in edits[scenario.case_id].items())
_wrap(f" [simulated {reviewer.role}] ", f"edited {changes}. {note}")
outcome.reviews.append("edit")
return {"type": "edit", "edited_action": {"name": action["name"], "args": new_args}}
if reviewer.decide(scenario.case_id, decision):
outcome.reviews.append("approve")
return {"type": "approve"}
outcome.reviews.append("reject")
return {"type": "reject", "message": note}In production the pause can last hours and the resume can run in another process. That works with a durable checkpointer, because the decision the reviewer saw is read back from the checkpoint rather than recomputed.
Example: refunds agent (finance)
examples/agents/langchain/finance_refund_agent.py
is a card and e-commerce refunds assistant. It looks the order up with
lookup_order, which is unguarded, and proposes
issue_refund(order_id, amount, reason), which is guarded by
Gate(guarantee="risk", target=0.05). The Gate is calibrated with
conformal risk control on 320 synthetic labelled decisions from
data/refund_requests.jsonl. Those labels come from a written policy
covering the window, the amount, the category, one refund per order,
fraud holds and photo evidence, plus manager-decided exceptions and 2%
label noise, generated by data/generate_refund_requests.py.
git clone https://github.com/rahvis/cci-sdk && cd cci-sdk
python examples/agents/langchain/finance_refund_agent.py # mock: offline, keyless
python examples/agents/langchain/finance_refund_agent.py --no-human-review # batch-job mode
python examples/agents/langchain/finance_refund_agent.py --interactive # you are the reviewerOutput in mock mode (the first run also calibrates the profile, in about a second; the lookup results of R-1002 and R-1003 are elided):
==============================================================================
LangChain refunds agent: calibrated guard on issue_refund
==============================================================================
Synthetic data for demonstration only. Not financial, credit or legal advice.
Calibrated guarantees support, but do not replace, your model-risk-management,
fair-lending and compliance review.
agent model: mock evidence model: mock human review: on (HumanInTheLoopMiddleware)
profile refund-approvals-v1: CRC, n=320 synthetic labelled decisions, status serving (calibrated on this run)
------------------------------------------------------------------------------
R-1001: Damaged mug, small refund, well inside the window
customer: Order ORD-90101 arrived with a cracked mug. I uploaded a photo.
Can I get the $14.99 back?
agent: proposes lookup_order(order_id="ORD-90101")
tool: [success] {"amount_paid": 19.98, "category": "home_goods",
"customer_tier": "standard", "delivered_days_ago": 3,
"delivery_status": "delivered", "final_sale": false,
"item_price": 14.99, "open_chargeback": false,
"open_fraud_flag": false, "order_id": "ORD-90101",
"payment_method": "card", "photo_on_file": true,
"purchased_days_ago": 7, "refunded_to_date": 0.0,
"shipping_fee": 4.99}
agent: proposes issue_refund(amount=14.99, order_id="ORD-90101",
reason="damaged_item")
guard: ALLOW: auto-approved under the calibrated risk bound (confidence
0.967, calibrated threshold 0.863).
guarantee: Expected rate of decisions that are auto-approved and
wrong is at most 0.05 (conformal risk control, n=320).
tool: [success] Refund of $14.99 issued to the card on ORD-90101
(reason: damaged_item).
agent: "Done. Refund of $14.99 issued to the card on ORD-90101 (reason:
damaged_item)."
------------------------------------------------------------------------------
R-1002: Change-of-mind return near the end of the window, shipping included
customer: I changed my mind about the coat from order ORD-90106. Please
refund everything I paid, $181.99.
agent: proposes lookup_order(order_id="ORD-90106")
[...]
agent: proposes issue_refund(amount=181.99, order_id="ORD-90106",
reason="changed_mind")
guard: ESCALATE: below the calibrated auto-approval threshold
(confidence 0.548, calibrated threshold 0.863).
guarantee: Expected rate of decisions that are auto-approved and
wrong is at most 0.05 (conformal risk control, n=320).
paused: HumanInTheLoopMiddleware interrupt; the run waits for a reviewer
review request for R-1002:
action: issue_refund({"amount": 181.99, "order_id": "ORD-90106", "reason": "changed_mind"})
why: below the calibrated auto-approval threshold (confidence 0.548, calibrated threshold 0.863).
guarantee: Expected rate of decisions that are auto-approved and wrong is at most 0.05 (conformal risk control, n=320).
[simulated refunds specialist] edited amount 181.99 -> 169.99.
Change-of-mind return: rule 3 refunds the
item price; the $12.00 shipping fee is not
refundable.
resume: Command(resume={"decisions": [{"type": "edit", "edited_action":
{"name": "issue_refund", "args": {"order_id": "ORD-90106",
"amount": 169.99, "reason": "changed_mind"}}}]})
note: a reviewer's edit is an explicit human approval: the edited call
runs as written and is not scored again
tool: [success] Note: a human reviewer replaced this tool call before
it ran. The call recorded in your message is the one
you produced, not the one that executed. This was
intentional and authorized. Do not re-issue your
original call. Executed instead: issue_refund with
arguments {"order_id": "ORD-90106", "amount": 169.99,
"reason": "changed_mind"}. Tool response: Refund of
$169.99 issued to the card on ORD-90106 (reason:
changed_mind).
agent: "A refunds specialist adjusted the amount before it was issued.
Refund of $169.99 issued to the card on ORD-90106 (reason:
changed_mind)."
------------------------------------------------------------------------------
R-1003: Second refund on an order that was already refunded
customer: I was charged twice for the earbuds on order ORD-90103. Please
refund the duplicate charge of $64.98.
agent: proposes lookup_order(order_id="ORD-90103")
[...]
agent: proposes issue_refund(amount=64.98, order_id="ORD-90103",
reason="duplicate_charge")
guard: ESCALATE: below the calibrated auto-approval threshold
(confidence 0.007, calibrated threshold 0.863).
guarantee: Expected rate of decisions that are auto-approved and
wrong is at most 0.05 (conformal risk control, n=320).
paused: HumanInTheLoopMiddleware interrupt; the run waits for a reviewer
review request for R-1003:
action: issue_refund({"amount": 64.98, "order_id": "ORD-90103", "reason": "duplicate_charge"})
why: below the calibrated auto-approval threshold (confidence 0.007, calibrated threshold 0.863).
guarantee: Expected rate of decisions that are auto-approved and wrong is at most 0.05 (conformal risk control, n=320).
[simulated refunds specialist] rejected
resume: Command(resume={"decisions": [{"type": "reject", "message": "A
full refund of $64.98 was already issued on this order; it
reversed the duplicate charge."}]})
tool: [error] User rejected the tool call for `issue_refund` with
reason: A full refund of $64.98 was already issued on
this order; it reversed the duplicate charge.
agent: "A refunds specialist reviewed this request and did not issue a
refund. A full refund of $64.98 was already issued on this
order; it reversed the duplicate charge."
------------------------------------------------------------------------------
Summary
case guard human review refund issued
R-1001 allow - $14.99 on ORD-90101
R-1002 escalate edit $169.99 on ORD-90106
R-1003 escalate reject none
guard evaluations: 3 (each proposed call is scored once; stored in the
checkpointed agent state and reused on resume)
------------------------------------------------------------------------------
The bundled calibration sets are small so the example runs quickly. For
production, calibrate on at least the recommended number of labelled examples
from your own traffic (about 1,000 for alpha=0.10), audit on held-out data,
and monitor for drift.- R-1001 scores 0.967, above the calibrated threshold of 0.863, so the refund runs with no interrupt.
- R-1002 asks for the full $181.99, including the $12.00 shipping fee, which rule 3 of the policy does not refund on a change-of-mind return. The request is also 27 days into a 30-day window. It scores 0.548 and pauses. The reviewer edits the amount to the item price, and LangChain runs the reviewer’s call and tells the model it was replaced.
- R-1003 is a second refund on an order already refunded in full. It
scores 0.007 and pauses; the reviewer rejects, and
issue_refundnever runs.
What the guarantee covers. guarantee="risk" bounds the rate of
auto-approved and wrong decisions over all requests, not the error rate
among approvals. On this calibration set 178 of 320 requests clear the
threshold, so an expected per-request rate of 0.05 allows up to about
0.05 / (178 / 320) = 9% of auto-approved refunds to be wrong. If your policy
is written per approved refund, use guarantee="fdr"
(Gate). The escalated requests are not covered by the
bound at all: they are exactly the ones a person decides.
In batch-job mode (--no-human-review) nothing pauses. The escalated calls
come back to the agent as an error ToolMessage and can be queued:
guard: ESCALATE: below the calibrated auto-approval threshold
(confidence 0.548, calibrated threshold 0.863).
guarantee: Expected rate of decisions that are auto-approved and
wrong is at most 0.05 (conformal risk control, n=320).
tool: [error] needs_human_review: The issue_refund action needs human
review before it can run: below the calibrated
auto-approval threshold (confidence 0.548, calibrated
threshold 0.863). Do not retry it; tell the user it has
been sent for review. (the guard decision is attached
under "cli")
agent: "This refund needs a refunds specialist's review before it can
be issued. I have added it to the review queue; you will get an
email when it is decided."
[...]
Summary
case guard human review refund issued
R-1001 allow - $14.99 on ORD-90101
R-1002 escalate queued none
R-1003 escalate queued none
guard evaluations: 3 (each proposed call is scored once)
batch mode: R-1002, R-1003 refused with an error ToolMessage and queued
for human reviewA second run reuses the cached profile (status serving (reused from the profile store)) and makes the same decisions.
Example: patient-portal triage (healthcare)
examples/agents/langchain/healthcare_patient_triage.py
triages new patient-portal messages and proposes
send_triage_advice(message_id, level). The level is one of self_care,
routine_appointment, urgent_care or emergency. The guard is a
four-option Set with APS at alpha=0.05, calibrated on 320 synthetic
labelled messages from data/triage_messages.jsonl. The labels come from
an explicit synthetic triage protocol covering red flags, urgent signs,
temperature, severity, age band and per-complaint durations, plus
nurse-decided vague messages and 1.5% label noise:
TRIAGE_SET = Set(
instructions=(
"Under the triage protocol in the context, which triage level does this patient-portal message "
"require? Apply the protocol's rules in order."
),
options=dict(triage.TRIAGE_LEVELS),
calibration_profile="portal-triage-v1",
alpha=0.05,
method="APS",
)def build_guard(client: LocalCLIClient) -> ToolGuard:
rule = GuardRule(
tool="send_triage_advice",
query=TRIAGE_SET,
context=triage_guard_context,
allow_labels=["self_care", "routine_appointment"], # urgent_care and emergency always go to a nurse
match_argument="level", # the set must be exactly the level the agent proposed
)
return ToolGuard(client, [rule])An automated reply goes out only when the calibrated 95% prediction set is
exactly one low-acuity level and that level is the one the agent proposed.
A set with two levels means the calibration needs both to reach 95%
coverage, so a nurse decides. match_argument="level" compares the set
with the level argument. When the set is a confident singleton for a
different level than the agent proposed, the call escalates instead of
running. urgent_care and emergency are not in allow_labels, so they
are never automated.
git clone https://github.com/rahvis/cci-sdk && cd cci-sdk
python examples/agents/langchain/healthcare_patient_triage.py
python examples/agents/langchain/healthcare_patient_triage.py --no-human-reviewOutput in mock mode (M-2001 and M-2003 abbreviated):
==============================================================================
LangChain patient-portal triage: calibrated guard on send_triage_advice
==============================================================================
Synthetic data for demonstration only. Not medical advice and not a medical
device. A calibrated guarantee bounds error rates on data like the calibration
set; it does not make any single recommendation safe. Keep a qualified
clinician in the loop and follow your institution's clinical governance.
agent model: mock evidence model: mock nurse review: on (HumanInTheLoopMiddleware)
profile portal-triage-v1: APS, alpha=0.05, n=320 synthetic labelled messages, status serving (reused from the profile store)
------------------------------------------------------------------------------
M-2001: Mild cold for 3 days; the agent proposes self_care
[...]
evidence: self_care 0.996, routine_appointment 0.002, urgent_care 0.001,
emergency 0.001
guard: ALLOW: the calibrated prediction set is exactly {self_care}.
guarantee: Contains the correct answer at least 95% of the time
on profile 'portal-triage-v1' (n=320).
tool: [success] Standard self_care advice sent to the patient for
message M-2001.
[...]
------------------------------------------------------------------------------
M-2002: Chest tightness, no red flags; the agent proposes routine_appointment
portal: New portal message M-2002 (age band 18-39): I am 29 and I have
had a tight feeling in my chest for 3 days. It feels mild. I
have not taken my temperature.
agent: proposes send_triage_advice(level="routine_appointment",
message_id="M-2002")
evidence: routine_appointment 0.656, urgent_care 0.328, self_care 0.012,
emergency 0.004
guard: ESCALATE: the calibrated prediction set is {routine_appointment,
urgent_care}, not a single allowed label.
guarantee: Contains the correct answer at least 95% of the time
on profile 'portal-triage-v1' (n=320).
paused: HumanInTheLoopMiddleware interrupt; the run waits for a nurse
review request for M-2002:
action: send_triage_advice({"level": "routine_appointment", "message_id": "M-2002"})
why: the calibrated prediction set is {routine_appointment, urgent_care}, not a single allowed label.
guarantee: Contains the correct answer at least 95% of the time on profile 'portal-triage-v1' (n=320).
[simulated nurse] approved
resume: Command(resume={"decisions": [{"type": "approve"}]})
tool: [success] Standard routine_appointment advice sent to the
patient for message M-2002.
agent: "Standard routine_appointment advice sent to the patient for
message M-2002. A nurse can see this conversation."
------------------------------------------------------------------------------
M-2003: Stroke warning signs; the agent proposes emergency
[...]
guard: ESCALATE: the calibrated prediction set is {emergency}, not a
single allowed label.
[...]
[simulated nurse] approved
[...]
note: Emergency advice is never automated here. A production portal
also shows fixed emergency instructions to the patient at once,
without waiting for the nurse queue.
------------------------------------------------------------------------------
M-2004: Cough for 25 days; the agent proposes self_care
portal: New portal message M-2004 (age band 40-64): I am 52 and I have
had a cough for 25 days. It feels mild. My temperature is 37.1
C.
agent: proposes send_triage_advice(level="self_care",
message_id="M-2004")
evidence: routine_appointment 0.991, self_care 0.004, urgent_care 0.004,
emergency 0.001
guard: ESCALATE: the calibrated prediction set is
{routine_appointment}, but the agent proposed 'self_care'.
guarantee: Contains the correct answer at least 95% of the time
on profile 'portal-triage-v1' (n=320).
paused: HumanInTheLoopMiddleware interrupt; the run waits for a nurse
review request for M-2004:
action: send_triage_advice({"level": "self_care", "message_id": "M-2004"})
why: the calibrated prediction set is {routine_appointment}, but the agent proposed 'self_care'.
guarantee: Contains the correct answer at least 95% of the time on profile 'portal-triage-v1' (n=320).
[simulated nurse] edited level self_care -> routine_appointment. A cough
lasting more than 21 days calls for a routine
appointment under rule 3.
resume: Command(resume={"decisions": [{"type": "edit", "edited_action":
{"name": "send_triage_advice", "args": {"message_id": "M-2004",
"level": "routine_appointment"}}}]})
note: a nurse's edit is an explicit human approval: the edited call
runs as written and is not scored again
[...]
------------------------------------------------------------------------------
Summary
case guard nurse advice sent
M-2001 allow - self_care
M-2002 escalate approve routine_appointment
M-2003 escalate approve emergency
M-2004 escalate edit routine_appointment
guard evaluations: 4 (each proposed call is scored once; stored in the
checkpointed agent state and reused on resume)- M-2001: the set is exactly
{self_care}, which is what the agent proposed, so the standard self-care advice is sent. - M-2002: chest discomfort without red flags. The evidence model splits
between
routine_appointmentandurgent_care, and the 95% set keeps both. A nurse approves the routine appointment. - M-2003: stroke warning signs. The set is
{emergency}and the agent agrees, butemergencyis not an automated level, so a nurse confirms. - M-2004: a 25-day cough. The set is the singleton
{routine_appointment}, while the agent proposedself_care. That is the casematch_argumentexists for. The nurse edits the level, and only the nurse’s level is sent.
What the guarantee covers. “Contains the correct answer at least 95%
of the time” is marginal coverage over messages like the calibration set.
It is not a statement about M-2001, and it is not conditional on complaint
or age band (use group_by for per-group coverage, with enough examples
per group). On the calibration messages, 215 of 320 sets are a single
level and 105 have two. The two-level sets are the price of 95% coverage
with this evidence model, and they are the messages a nurse sees.
The emergency pathway must not wait on a review queue. In this example the guard never sends emergency advice on its own, which is right for the automated channel. A production portal should also show fixed, rule-based emergency instructions (call emergency services) to the patient at once, whatever the agent or the guard decides, and route the message to a nurse at the same time. The protocol here is a synthetic teaching example, not clinical guidance.
Pitfalls
- Use
langchain.agents.create_agent.langgraph.prebuilt.create_react_agentis deprecated, andAgentExecutorandinitialize_agentmoved to the separatelangchain-classicpackage. The middleware in this integration only works withcreate_agent. - Interrupts need a checkpointer and a
thread_id. Without a checkpointer, human review cannot pause the run. Resume on the samethread_id. Use a durable checkpointer (for example Postgres) shared by every worker in production: the guard’s decisions live in the checkpoint, and a resume on another worker reads the decision the reviewer saw instead of scoring again. - One decision per pending action, in order. The resume command,
Command(resume={"decisions": [...]}), must hold exactly as many decisions aspending_reviews(result)returned, and each must be one ofapprove,editorreject(cli_middleware(guard, allowed_decisions=...)changes the set). A count mismatch or an unlisted type raisesValueError. When one model turn proposes several escalated calls, they arrive in a single interrupt. - An edit is a human approval. The reviewer’s arguments run as written
and are not scored again, even if the edit changes the outcome. Keep the
reviewer’s decision in your audit log alongside
guard_decisions(result). - A refused call returns to the model. A real model may try again. The
refusal message tells it not to retry. A retry is a new tool call: it is
checked again and escalates again, so add
ToolCallLimitMiddlewareif repeated attempts would cost reviewer time. - Read
version="v2"results through the helpers.invoke(..., version="v2")returns aGraphOutput(.value,.interrupts), and the defaultversion="v1"returns a dict with"__interrupt__".pending_reviewsandguard_decisionsaccept both. - Self-hosted models need tool calling switched on in the server. Start
vLLM with
--enable-auto-tool-choice --tool-call-parser gemma4, or SGLang with--tool-call-parser gemma4. Otherwise Gemma writes tool calls as text, the agent never calls a tool, and the guard never runs. ForChatOpenAIagainst these servers, pass a non-emptyapi_key("EMPTY"), setuse_responses_api=False, and makemodelequal the served model name. - Gemini through
init_chat_modelneeds thegoogle_genai:prefix. A baregemini-...name resolves to Vertex AI. - Do not let the model grade itself. Never pass the agent’s own confidence as a tool argument to drive the guard: that is verbalized confidence, which is what the calibration replaces. The guard’s context should come from your system of record (here the order and message stores), built by the same function as the calibration examples.
- Testing with fake models. LangChain’s fake chat models do not
implement
bind_tools, whichcreate_agentcalls. Subclass one and returnselffrombind_tools, as the examples do. Build a fresh fake per run, becauseFakeMessagesListChatModelkeeps its position in the script on the instance.
Related
- Local mode:
LocalCLIClient, profile files, fingerprints, sizes and fail-closed behavior. - Bring your own model: every agent and evidence model, access levels, and Gemma on vLLM and SGLang.
- Risk-sensitive domains: what each example guards, why it uses its primitive, and what stays with people.
- Gate and Set: the two primitives used here.
- Guarantees: what each guarantee type does and does not promise.