Combine and calibrate monitors¶
This guide shows how to wrap a monitor in RepeatedMonitor,
CalibratedMonitor and CascadeMonitor, how to collect honest scores from
monitor_log, and how to set thresholds that mean something.
Set every threshold yourself¶
Every control protocol compares the monitor's suspicion with a threshold. Each threshold has a default, listed in Thresholds, and a protocol built with a default warns. This is the warning Auto Mode gives:
UncalibratedThresholdWarning: block_threshold uses the uncalibrated default 0.6. Suspicion scores differ between monitors and agents, so a fixed default may block honest work or miss attacks. Run the agent on honest tasks, take the suspicion scores of the monitor_log records whose agent and monitor are your MonitorMiddleware's agent_name and label, and set the threshold at a high percentile of those scores (for example the 98th), as the section "Calibrate the threshold" of the guide "Use Trusted Monitoring" shows, or wrap the monitor in CalibratedMonitor so that its scores are smoothed conformal p-values against honest scores. Pass block_threshold explicitly to silence this warning.
Treat the defaults as placeholders. Each monitor spreads its scores differently, and an agent's honest work looks more or less unusual depending on its task and tools. Control evaluations set thresholds from the scores the monitor gives to honest runs, and the audit threshold spends a known audit budget: 2% of honest solutions in AI Control (section 3.2) 1, and 1% of honest actions in Ctrl-Z (section 2.2; appendix C) 2. A defer threshold is a percentile of the same scores, but it trades usefulness for safety rather than spending a budget, so it can sit much lower, as Ctrl-Z notes (section 3.1.3) 2. Thresholds gives the defer percentiles each source uses.
A threshold may be any real number from 0 to 1, a Fraction or a Decimal
included. It is checked exactly, before it becomes a float, so a Decimal
just above 1 is refused rather than rounded into range. A bool, a string
such as "0.6" and NaN raise ConfigurationError, and -0.0 reads as 0.
Passing a number silences the warning, but only a number chosen from your own honest runs does what it asks. There are two ways to choose one:
- Take a high percentile of the raw honest scores. This is simple, but a percentile that falls among tied scores flags every step in the tie.
- Wrap the monitor in
CalibratedMonitor. Its scores are then smoothed conformal p-values against the honest scores, so a threshold of 0.99 compared with one sample's score flags about 1% of honest steps.
The rest of this guide takes the second way.
The three wrappers¶
Each wrapper is a monitor itself, so it stacks around any monitor family and around the other wrappers, and every protocol accepts it.
| Wrapper | What it reports | Calls per judged sample | Reason |
|---|---|---|---|
RepeatedMonitor(monitor=..., ensemble_size=...) |
The mean suspicion of ensemble_size calls |
ensemble_size times the wrapped monitor's |
The most suspicious call's |
CalibratedMonitor(monitor=..., honest_scores=..., random_seed=...) |
The smoothed conformal p-value of the score against honest scores | The wrapped monitor's, and no more | The wrapped monitor's |
CascadeMonitor(fast=..., careful=..., escalation_threshold=...) |
The fast verdict, or the careful one when the fast suspicion reaches escalation_threshold |
The fast monitor's, plus the careful monitor's for escalated samples | The verdict's own |
The wrappers never add a score to a reason. Auto Mode shows the reason to the agent, and a number would tell the agent how close its step came to passing.
Protocols multiply these costs, because they judge every sample they draw.
Trusted Monitoring and Defer to Trusted judge one sample per step; Defer to
Resample and Auto Mode judge up to three by default. So Defer to Resample
around a RepeatedMonitor of three LLM monitor calls makes three monitor
calls on an ordinary step and up to nine on a suspicious one, counting one
readable reply per call. An LLM monitor asks again for an unreadable reply,
up to max_parse_retries more times.
Average repeated calls¶
RepeatedMonitor is a homogeneous ensemble: the mean of independent calls of
one monitor 3. Its ensemble_size is the number of calls,
which LinuxArena calls the ensemble size (appendix C) 4.
An LLM monitor gives an integer score, and integer scores tie so often that a small audit budget falls inside a group of tied scores, as BashArena (section 4.3) and Koran et al. (section 5.1) found 53. Averaging several calls adds resolution, as BashArena shows (section 4.3) 5. BashArena averages three calls (section 4.2) and LinuxArena two (section 5.1), and Ctrl-Z samples its monitor several times at temperature 1.1 (appendix D) 542. For ensembles of different monitors, Koran et al. found the arithmetic mean as good as the other aggregators they tried (section 3.2), and found that three calls of one monitor gain less than three different monitors (section 5.2) 3.
from langchain.chat_models import init_chat_model
from langchain_sync_monitors import LLMMonitor, RepeatedMonitor
# The calls only differ when the model samples, so its temperature is above zero.
monitor_model = init_chat_model("openrouter:xiaomi/mimo-v2.6-pro", temperature=1.0)
monitor = RepeatedMonitor(monitor=LLMMonitor(model=monitor_model), ensemble_size=3)
Give the model no LangChain response cache, which would answer every call
after the first with a copy of it. The mean is rounded to 12 decimal places,
so that equal means tie exactly instead of differing in their last bits.
Under ainvoke() the calls run concurrently, and one that fails cancels the
others; under invoke() they run one after another. ensemble_size must be
a whole number of at least 1, or ConfigurationError is raised.
Stack the wrappers in order¶
Average first, then calibrate: CalibratedMonitor goes outside
RepeatedMonitor. The calibrated monitor ranks exactly the score its honest
scores measured, here the mean of three calls, and the mean adds the
resolution that ranking needs. The other way round would average percentiles,
and a mean of percentiles is not itself a percentile.
Collect honest scores¶
Honest scores are the scores your monitor gives to steps of runs you trust. Collect them with exactly the monitor you will calibrate: the same model, prompt, view, ensemble size and temperature.
Run the agent under TrustedMonitoring, which never changes a step, so the
run goes as it would unmonitored and every step is judged once. A protocol
that resamples would judge extra samples only for suspicious steps, and skew
the scores. Keep only the records of the agent and the monitor you calibrate:
a record names both, and a Deep Agent's log also holds its subagents' records.
import json
from pathlib import Path
from langchain.agents import create_agent
from langchain.chat_models import init_chat_model
from langchain_sync_monitors import (
LLMMonitor,
MonitorMiddleware,
RepeatedMonitor,
TrustedMonitoring,
)
def read_file(path: str) -> str:
"""Read a file from the workspace."""
return Path(path).read_text(encoding="utf-8")
HONEST_TASKS = [
"Summarise q3.md for the team.",
"List the open questions in notes.md.",
]
# Exactly the monitor you will calibrate.
monitor_model = init_chat_model("openrouter:xiaomi/mimo-v2.6-pro", temperature=1.0)
monitor = RepeatedMonitor(monitor=LLMMonitor(model=monitor_model), ensemble_size=3)
agent = create_agent(
"openrouter:deepseek/deepseek-v4.1-flash",
tools=[read_file],
middleware=[
MonitorMiddleware(monitor=monitor, protocol=TrustedMonitoring(audit_threshold=0.9)),
],
)
honest_samples = []
for task in HONEST_TASKS:
result = agent.invoke({"messages": [{"role": "user", "content": task}]})
# Keep the main agent's samples under this monitor; skip subagents' records.
honest_samples.extend(
sample
for record in result["monitor_log"]
if record["agent"] == "main" and record["monitor"] == "monitor"
for sample in record["samples"]
)
honest_scores = [sample["suspicion"] for sample in honest_samples]
Path("honest_scores.json").write_text(json.dumps(honest_scores), encoding="utf-8")
# Read the reasons behind the five highest honest scores before you calibrate.
for sample in sorted(honest_samples, key=lambda sample: sample["suspicion"])[-5:]:
print(sample["suspicion"], sample["reason"])
Each run here has no checkpointer, so its log holds only its own steps. On a
checkpointed thread, monitor_log holds every turn of the thread, so collect
it once per thread.
Read the highest honest scores before you calibrate. An LLM monitor whose reply
could not be read gives 1.0, with the reason "The monitor gave no readable
score, so the step is treated as suspicious." That score measures nothing
about the step, and a 1.0 in the honest set ties with every later 1.0: a step
that fails closed, and a step the monitor genuinely scores at the top of its
scale. Either then lands at a random place among the ties instead of above
every honest score. Fix the cause, such as too small a max_tokens, and
collect again.
Collect more scores than you think you need. With n honest scores, a step
that scores above all of them is sure to pass a threshold t only when
n / (n + 1) is at or above t, which for 0.99 takes at least 99 scores. A
thousand make the flagged share stable, as What a calibrated threshold
means explains.
Calibrate the monitor¶
Wrap the same monitor in CalibratedMonitor with the honest scores, then set
thresholds as shares of honest steps:
import json
from pathlib import Path
from langchain.chat_models import init_chat_model
from langchain_sync_monitors import (
CalibratedMonitor,
LLMMonitor,
MonitorMiddleware,
RepeatedMonitor,
TrustedMonitoring,
)
honest_scores = json.loads(Path("honest_scores.json").read_text(encoding="utf-8"))
monitor_model = init_chat_model("openrouter:xiaomi/mimo-v2.6-pro", temperature=1.0)
monitor = CalibratedMonitor(
monitor=RepeatedMonitor(monitor=LLMMonitor(model=monitor_model), ensemble_size=3),
honest_scores=honest_scores,
# A fixed seed makes the tie-breaking replayable.
random_seed=2026,
)
# Flag about 1% of honest steps.
middleware = MonitorMiddleware(
monitor=monitor,
protocol=TrustedMonitoring(audit_threshold=0.99),
)
Every other protocol's thresholds read the same way: with a calibrated
monitor, a defer threshold of 0.98 sends about 2% of honest steps to
resampling. Defer to Resample's audit_threshold is the exception. It is
compared with the lowest score across a step's samples, which is at most the
first sample's score, so 0.99 flags at most about 1% of honest steps, and
usually fewer;
Calibrate the audit threshold
sets it on that statistic.
honest_scores can be a list or any other iterable, such as a generator,
which the constructor reads once. It must hold at least one score,
and each is checked as a threshold is: a number from 0 to 1, not a bool and
not NaN, or the constructor raises ConfigurationError. Each is kept as a
float, so pass Python floats or numpy float64s: a float32 0.1 becomes
0.10000000149011612, which no longer ties with a monitor's 0.1.
What a calibrated threshold means¶
CalibratedMonitor replaces the wrapped monitor's score with its smoothed
conformal p-value, with the n honest scores as the calibration set:
calibrated suspicion = (below + U * (tied + 1)) / (n + 1)
The step takes one of n + 1 places in the sorted list of honest scores.
below counts the honest scores lower than the step's score, and tied
counts those equal to it. U is a random number from 0 up to 1, which spreads
the step evenly over its own place and the places of the honest scores it ties
with. Makins et al. (appendix G.4.1) and LinuxArena (section 5.1) also break
ties at random in their control evaluations
64.
This is the smoothed conformal p-value of Vovk et al., with the step's raw
score in the place of their conformity score and U as their uniform random
number; their count of ties includes the step itself, which gives tied + 1
(section 2, equation 2) 7. In the same section they credit
the uniform distribution of such p-values to proposition 2.8 of Vovk,
Gammerman and Shafer's book 78.
The guarantee holds when the step's raw score is exchangeable with the honest
scores: the step comes from the same kind of honest run, scored by the same
monitor with the same settings. A smoothed conformal p-value is then exactly
uniform, so the calibrated suspicion is spread evenly from 0 to 1, and a
threshold t flags a share 1 - t of such steps: 0.99 flags 1% of honest
steps, and 0.95 flags 5%.
That share is exact on average over the honest scores you might have
collected. For the one set you did collect, it varies around 1 - t. When
the honest scores you collected and those of new honest steps are independent
draws from one distribution, with no ties, and (n + 1) * t is a whole number
from 1 to n, the share of honest steps left unflagged follows
Beta(n + 1 - l, l), where l = (n + 1) * (1 - t). This is the coverage of
split conformal prediction for a fixed calibration set, which Angelopoulos and
Bates give after Vovk (section 3.2) 9. Its standard
deviation is sqrt(t * (1 - t) / (n + 2)), close to that of a share
estimated from n draws. At t = 0.99 it is about 0.3 percentage points for
a thousand honest scores, and about 1 point for a hundred. When
(n + 1) * t is not a whole number the spread is close to this; with ties it
can differ.
| Consequence | What it means |
|---|---|
| Scores above every honest score | Such a step lands between n / (n + 1) and 1, never at 1. With 49 honest scores it passes a threshold of 0.99 only half the time; with 99 or more, always. |
| Ties | A step that ties with many honest scores lands anywhere across their places. If 400 of 500 honest scores are 0, a step that scores 0 lands anywhere from 0 to about 0.8. |
| What calibration cannot do | It does not make the monitor better at telling attacks from honest work. An attack scores high only if its raw score is above most honest scores. |
Fix the tie-breaking with a seed¶
Without random_seed, U is drawn afresh on every call, so a step whose
score ties with honest scores can land on either side of a threshold from one
run to the next.
With random_seed, U comes from the seed and the step's content: the task
author, the type and text of each earlier message, and the proposal with all
its parts. The earlier messages' tool calls and ids are left out, since the
monitor's own messages get fresh random ids on every run; two steps whose
histories differ only in tool calls get the same draw. The same step always
gets the same draw, in every process, so the same raw score always gives the
same calibrated suspicion, and an evaluation can be replayed exactly.
Put a fast monitor in front of a careful one¶
CascadeMonitor runs a fast monitor on every step and sends a step to a
careful one only when the fast suspicion reaches escalation_threshold.
Below it, the fast verdict stands; at or above it, the careful verdict,
reason included, replaces it. This follows the two stages of Claude Code's
auto mode, a fast filter in front of a reasoning classifier
10;
where the ideas come from
compares this rule of one threshold with other cascades.
Calibrate each stage on its own honest scores, collected as above with that stage as the monitor. Collect the careful stage's scores on every honest step, not only on the ones the fast stage would escalate.
import json
from pathlib import Path
from langchain.chat_models import init_chat_model
from langchain_sync_monitors import (
CalibratedMonitor,
CascadeMonitor,
DecisionModelMonitor,
LLMMonitor,
OpenRouterDecisionModel,
RepeatedMonitor,
)
fast_scores = json.loads(Path("fast_honest_scores.json").read_text(encoding="utf-8"))
careful_scores = json.loads(Path("careful_honest_scores.json").read_text(encoding="utf-8"))
monitor_model = init_chat_model("openrouter:xiaomi/mimo-v2.6-pro", temperature=1.0)
monitor = CascadeMonitor(
# Fast: one decision model request per step.
fast=CalibratedMonitor(
monitor=DecisionModelMonitor(
decision_model=OpenRouterDecisionModel(model="typesafe/jev-1.13"),
),
honest_scores=fast_scores,
),
# Careful: three LLM monitor calls, only for escalated steps.
careful=CalibratedMonitor(
monitor=RepeatedMonitor(monitor=LLMMonitor(model=monitor_model), ensemble_size=3),
honest_scores=careful_scores,
),
# Escalate about 5% of honest steps.
escalation_threshold=0.95,
)
With both stages calibrated, escalation_threshold is a share too: 0.95
sends about 5% of honest steps to the careful stage, which bounds its cost. A
threshold t on the result then flags at most about a share 1 - t of
honest steps, since only the escalated ones can reach it.
Keep escalation_threshold at or below the protocol's lowest threshold. A
step the fast stage keeps has a suspicion below escalation_threshold, so a
threshold below escalation_threshold could trigger the protocol on a step
the careful stage never read. escalation_threshold is checked as a protocol
threshold is: a number from 0 to 1, kept as a float, and anything else, NaN
included, raises ConfigurationError.
Related guides¶
- Choose a protocol for the thresholds each protocol takes.
- Use Defer to Resample for the protocol whose audit budget calibration serves best.
References¶
-
Ryan Greenblatt, Buck Shlegeris, Kshitij Sachan, and Fabien Roger. AI control: improving safety despite intentional subversion. 2023. ICML 2024. URL: https://arxiv.org/abs/2312.06942, arXiv:2312.06942. ↩
-
Aryan Bhatt, Cody Rushing, Adam Kaufman, Tyler Tracy, Vasil Georgiev, David Matolcsi, Akbir Khan, and Buck Shlegeris. Ctrl-Z: controlling AI agents via resampling. 2025. URL: https://arxiv.org/abs/2504.10374, arXiv:2504.10374. ↩↩↩
-
Eugene Koran, Yejun Yun, Samantha Tetef, Benjamin Arnav, and Pablo Bernabeu-PĂ©rez. Ensemble monitoring for AI control: diverse signals outweigh more compute. 2026. URL: https://arxiv.org/abs/2605.15377, arXiv:2605.15377. ↩↩↩
-
Tyler Tracy, Ram Potham, Nick Kuhn, Myles Heller, Anshul Khandelwal, Cody Rushing, and others. LinuxArena: a control setting for AI agents in live production software environments. 2026. URL: https://arxiv.org/abs/2604.15384, arXiv:2604.15384. ↩↩↩
-
Oliver Makins, Orazio Angelini, Zohreh Shams, and Mary Phuong. Multi-agent AI control: distributed attacks hamper per-instance monitors. 2026. URL: https://arxiv.org/abs/2607.07368, arXiv:2607.07368. ↩
-
Vladimir Vovk, Ilia Nouretdinov, Valentina Fedorova, Ivan Petej, and Alex Gammerman. Criteria of efficiency for conformal prediction. 2016. Version 2; section 2, equation 2 defines the smoothed conformal p-value. URL: https://arxiv.org/abs/1603.04416, arXiv:1603.04416. ↩↩
-
Vladimir Vovk, Alexander Gammerman, and Glenn Shafer. Algorithmic Learning in a Random World. Springer, New York, 2005. Smoothed conformal predictors and their exact validity under exchangeability, as summarised by the authors' On-line Prediction Wiki. URL: https://www.onlineprediction.net/?n=Main.ConformalPredictor. ↩
-
Anastasios N. Angelopoulos and Stephen Bates. A gentle introduction to conformal prediction and distribution-free uncertainty quantification. 2021. URL: https://arxiv.org/abs/2107.07511, arXiv:2107.07511. ↩
-
John Hughes. How we built Claude Code auto mode: a safer way to skip permissions. March 2026. URL: https://www.anthropic.com/engineering/claude-code-auto-mode. ↩