A detector is any object satisfying the Detector protocol
(src/snagline/detectors/base.py). It sees every StepEvent, keeps whatever
per-episode state it needs, and returns a FailureRisk (or None) from each
observe() call. That's the whole contract.
from snagline.events import StepEvent
from snagline.risk import FailureRisk
class MyDetector:
name = "my_detector"
def __init__(self, config=None):
self._state = {} # keyed by episode_id -- a Monitor is shared across episodes
def observe(self, event: StepEvent) -> FailureRisk | None:
... # update state; return a FailureRisk when tripped, else None
def reset(self, episode_id: str) -> None:
self._state.pop(episode_id, None) # called on Monitor.end_episode()Register it by constructing the Monitor yourself:
from snagline import Monitor
monitor = Monitor.default()
monitor._detectors.append(MyDetector()) # or: Monitor(default_detectors + [mine], [sink])(For a first-class API, pass a full detector list to Monitor(...) -
Monitor.default() is just a convenience preset.)
-
O(1) amortized per step, no network, no LLM calls.
ingest()runs on every step of a week-long run; anything slower than ~100 µs is a bug. -
Never read
StepEvent.metadata. Detectors reason on hashes, timings, counts, and booleans only. This is the privacy property adopters rely on. -
Never raise on bad input. The Monitor is fail-open by default and will swallow the exception, but a detector that throws on malformed fields is effectively disabled from then on. Validate defensively.
Note: fail-open is the default but not unconditional.
Monitor(..., fail_open=False)makes detector and sink exceptions propagate (this is how the test suite asserts the fire-and-forget contract). Treat fail-open as the production-safe default; flip it off only in tests or in callers that want strict error reporting. -
Key all state by
episode_idand clear it inreset(). A single Monitor can watch several episodes concurrently. -
Prefer no false positives on healthy traffic. A noisy detector gets uninstalled; look at the healthy fixtures in
tests/fixtures/trajectories/as the bar (your detector must stay silent on them - see how the existing detector tests assert both detection and silence).
detectors/loop.py- sliding-window repetition count onaction_signaturedetectors/error_cascade.py- consecutive and windowed error countsdetectors/latency_anomaly.py- Welford baseline + CUSUM deviation per tooldetectors/stagnation.py- all-time novelty set plus a sliding novelty sharedetectors/side_effect_guard.py- per-episode duplicate count of host-declared non-idempotent actionsdetectors/goal_drift.py- compares a live run to a persistedBaselineProfile(structural error rate, latency, tool set)drift/goal_drift.py- semantic embedding centroid drift vs the same profile (snagline[drift], issue #81)detectors/ml_ensemble.py-MLOrchestratorcombining base detector scoresdetectors/token_runaway.py- token-volume CUSUM + per-episode budget envelopedetectors/meltdown.py- sliding-window entropy collapse/thrash detectiondetectors/silent_abort.py- end-of-episode completion check viafinalize
Each has a matching test in tests/detectors/ showing the synthetic-sequence
pattern (injected failure fires; healthy sequence stays silent). Copy that
test shape for your own detector.
LoopDetector has three opt-in extensions for failure shapes that plain
repetition counting misses. All are off by default: with stock config the
detector's behavior is exactly the plain path above.
- Near-duplicate (
Config(loop_near_duplicate_enabled=True)): retries whose signatures differ only by volatile identifiers (uuid-shaped substrings, digit runs) collapse onto one normalized key before hashing, then feed the same window and threshold logic as the plain path. Trigger:near_duplicate_loop. The default normalizer is a documented heuristic (uuid-like substrings become one token, remaining digit runs become#); it is deliberately blunt, and against opaque hex digests collapsing digits raises collision odds, so treat enabling it as a deliberate choice. Swap in your own strategy withLoopDetector(config=cfg, normalizer=fn), wherefnmaps a signature string to its normalized form. - Cycle (
Config(loop_cycle_enabled=True)): A,B,A,B,... periodicity that never repeats one action often enough to triprepeat_threshold. After each step an ascending scan (O(window)) finds the window content's minimal period p; it fires once when p lies inside the configured band (loop_cycle_min_periodtoloop_cycle_max_period, default 2..6) and the recentloop_cycle_window_sizewindow holds at least two full periods, then re-arms when periodicity breaks. Filtering on the true minimum means a custom band genuinely suppresses faster loops instead of re-flagging them through a multiple, and uniform repetition (minimal period 1) is always ignored: single-action loops belong to the plain loop/stall modes. Trigger:cycle. - Stall (
Config(loop_stall_enabled=True)): N consecutive identical signatures with no progress fires afterloop_stall_steps(default 25). Wall-clock deltas never reset the streak: zero-delta steps count toward it (a frozen clock is itself evidence of a stall), and positive deltas do not reset it either (tight retries burn real time while going nowhere); only a different signature restarts the count. Trigger:stall.
The trigger strings near_duplicate_loop, cycle, and stall are API:
downstream policy layers map them by name. When several shapes fire on the
same step the plain-loop risk keeps precedence; each mode still advances its
state every step, so nothing is lost.
Detectors answer "did this episode go wrong?". Authorization answers "may this tool run?". They are different questions with opposite failure postures, so they live in different modules and neither imports the other.
from snagline.authorize import Policy, allow_tools, deny_resource_glob
from snagline.adapters.claude_code import decide_payload
policy = Policy(
rules=(
allow_tools("Read", "Grep", "Glob"),
deny_resource_glob("*.pem", "/etc/ssh/*"),
),
default_decision="deny", # allowlist: anything unnamed is refused
)
response = decide_payload(payload, policy) # None, or the PreToolUse JSONFail-open is the default and is structural. Monitor.ingest() cannot reach
this module, so Config.fail_open=True is still a guarantee about the
detection path rather than a behaviour that could regress. Every uncertainty
resolves to allow:
| Situation | Default | Opt-in |
|---|---|---|
| No rule matched | allow (default_decision) |
default_decision="deny" |
| A rule raised | allow |
rule_error_decision="deny" |
| Unrecognised tool | allow |
unknown_tool_decision="deny" |
PreToolUse payload with no tool_name |
allow (no decision emitted) |
not overridable, on purpose |
Strictest verdict wins, not first match. Every rule is evaluated and the
strictest (deny > ask > allow) decides, so rule order cannot change an
outcome. An allowlist written above a denylist is a bug that silently
re-opens what the denylist closed; that mistake is unrepresentable here.
An allow emits nothing. decide_payload returns None for allow on
purpose: an explicit "allow" would override Claude Code's own permission
prompts, making this hook a security downgrade. Emitting nothing leaves the
native flow intact, so the gate can only tighten what the host already asks.
A policy that fails to parse is fatal. policy_from_file raises
PolicyError rather than degrading to allow. A typo'd rule that silently does
nothing is the same invisible failure the exception exists to prevent; what an
unloadable policy means for your host is your decision, not the library's.
Scope boundary. This module does not inspect content for secrets or PII, does not score risk, and does not persist an audit trail. A policy is a set of rules over which tool is being called on what resource.
LoopDetector, ErrorCascadeDetector, and LatencyAnomalyDetector ship in
Monitor.default(). The detectors below are opt-in behind config flags so the
zero-dependency preset (and its published bench numbers) are unchanged.
src/snagline/baseline.py fits a BaselineProfile (per-tool latency
mean/std/min/max and error rate) from a JSONL trajectory. The snagline baseline <trajectory> [--output baseline.json] CLI command persists one, and
load_baseline / save_baseline round-trip it.
GoalDriftDetector(baseline=profile, config=cfg) flags a live run that
diverges from that healthy reference: rising error rate (beyond
goal_drift_error_tolerance), latency blowing past the healthy mean by
goal_drift_latency_k sigmas, or tools that never appeared in the baseline. A
zero-variance baseline uses a floored spread (5% of mean, min 1 ms) so tiny
deviations are not treated as infinite z. Enable it with
Config(goal_drift_enabled=True, goal_drift_baseline=profile).
SemanticGoalDriftDetector(baseline=profile, config=cfg, embedder=None) is
the optional embedding counterpart to GoalDriftDetector. It compares the
running centroid of embedded structural labels against the persisted
BaselineProfile.embedding_centroid built by fit_semantic_baseline
(drift/goal_drift.py).
- What is embedded: a short label built from
action_type,tool_name, anderror_typeonly. Prompt or response content andmetadataare never read, never logged, and never persisted. The only stored artifact is an averaged vector of floats inside the baseline JSON. - How it decides: cosine similarity between the live running centroid
and the healthy reference gives a deviation
1 - cos. Values withinsemantic_drift_tolerance(default 0.3) are treated as noise. Above it, a CUSUM accumulatessignal - cusum_kand fires agoal_driftrisk when the debt reachessemantic_drift_cusum_h(defaultsk=0.05,h=0.5). After firing the accumulator re-arms, so persistent drift re-alarms later. Fewer thansemantic_drift_min_samples(default 10) live steps never fire. - Extra and laziness:
pip install snagline[drift](sentence-transformers). The package imports without the extra; the transformer is loaded lazily, exactly once, on first use. An injectableembedder=callable(StepEvent) -> Sequence[float]bypasses the heavy dependency entirely and is the path the test suite uses, so no torch is needed to run the tests. Passmodel_loaderto control loading if you embed elsewhere. - Fail-open and privacy: model load failures, inference exceptions,
degenerate or dimension-mismatched embeddings, and a baseline without an
embedding_centroidall leave the detector permanently inert, logged at most once, never crashing or stalling the host. Dimension is checked on the first scored step; the detector latches off after a mismatch. - Wiring: append to the Monitor's base list. Under
Monitor.default()a semantically fitted profile plusConfig(semantic_drift_enabled=True, goal_drift_baseline=profile)adds it to the base set. Withml_ensemble_enabledas well it joins the same noisy-ORMLOrchestratorthat already wraps the ESN ensemble (issue #80), so itsgoal_drifttrigger becomesml_ensemblethrough the ensemble. - Snapshot: implements
dump_state/load_state(per-episode running sum, count, and CUSUM debt) and participates inMonitor.snapshot/restorelike any stateful detector.
Fit a baseline that carries semantics from the same healthy trajectory:
from snagline.drift.goal_drift import fit_semantic_baseline
profile = fit_semantic_baseline(healthy_events, embedder, model="all-MiniLM-L6-v2")
save_baseline(profile, "baseline.json") # JSON now carries embedding_centroidYou can also fit the same profile directly from the CLI without writing Python: snagline baseline trajectory.jsonl --output baseline.json --semantic --semantic-model all-MiniLM-L6-v2 streams the JSONL fail-soft and builds both the structural stats and the embedding centroid via the guarded sentence-transformers import, then persists it with save_baseline. Missing extra or model-load failure prints a pip install snagline[drift] hint and exits non-zero without writing a half-fitted file; without --semantic the command is byte-identical to today.
Or build one structurally with snagline baseline and without semantics;
the detector then stays inert (the intended default) until you give it a
reference it can compare against.
Config(calibration="auto", calibration_baseline=profile) (or
calibration_baseline_path="baseline.json") retunes the tier-1 detectors from
the healthy profile instead of using worst-case hand-tuned constants:
- Error-cascade thresholds become the smallest windowed/consecutive error
counts whose exceedance probability under the deployment's observed error
rate stays within
calibration_alpha(default 0.001). The planning rate is max(pooled rate, p99 of per-tool rates); tools with fewer than 20 samples are excluded from the percentile. - The latency/CUSUM detector starts frozen at each tool's healthy mean and
floored spread when the profile holds at least
cusum_min_samplestimed samples for it (latency_count-- the seeded moments are computed over timed calls only, so a tool the profile saw often but never timed keeps learn-then-freeze instead of freezing onto a zero mean): episodes shorter than the old warm-up are monitorable from their first step.
Safety rails: derived counts clamp into [2, hand-tuned default] so auto can
only ever be more sensitive than shipped behavior, and with no usable baseline
every default stays exactly as today. Baseline loading and derivation are
fail-open: any problem logs one warning and falls back. The loop detector
keeps hand-tuned thresholds because profiles deliberately store no
action-repetition evidence (no content, project.md §1.4).
MLOrchestrator(detectors, config, model=None) combines base detector scores
into one stronger risk. The default combiner is a transparent noisy-OR
(1 - prod(1 - score_i)), which boosts confidence when multiple independent
detectors agree. Pass model=callable(scores) -> float (e.g. a fitted
scikit-learn pipeline from the ml extra) to replace the combiner. Enable it
with Config(ml_ensemble_enabled=True); Monitor.default() then wraps the
base detectors in one orchestrator so there is no double counting. The
orchestrator forwards finalize, dump_state, and load_state to its bases,
so orchestrated completion checks and snapshots keep working.
StagnationDetector(config=cfg) asks a question the loop detector cannot:
not "is it repeating?" but "is it discovering anything?". It tracks the share
of never-before-seen action_signature values in the last
stagnation_window_size steps (default 50). When that novelty share stays
below stagnation_min_novelty (default 0.05) for stagnation_patience
(default 2) consecutive full-window observations, it emits exactly one risk
(score 0.6, trigger stagnation). Novelty recovering resets the stale
counter, so a later collapse fires again: one alert per stagnation period,
never one per step.
The two detectors are complementary by construction. Near-duplicate actions
with slightly varied arguments produce fresh signatures each time and evade
exact-match loop windows entirely; the all-time novelty set still collapses
because the agent keeps drawing from the same small template space. The tests
in tests/detectors/test_stagnation.py prove a sequence that trips
stagnation while the loop detector stays silent.
Memory, stated honestly: the per-episode seen_all_time set grows
monotonically by design and only reset(episode_id) (called by
Monitor.end_episode()) releases it. Signatures are full 64-character hex
digests since issue #15; measured on CPython 3.14, retaining one costs about
105 bytes plus roughly 70 bytes of amortized set overhead, so budget around
175 bytes per unique action: a pathological all-unique 100k-step episode
holds on the order of 17 MB. Typical episodes repeat heavily and cost far
less. The sliding counter itself is bounded (window_size booleans per
episode).
Enable it with Config(stagnation_enabled=True). It ships opt-in so the
zero-dependency preset and the published bench numbers are untouched.
Validation contract (issue #132): stagnation_window_size,
stagnation_min_novelty, and stagnation_patience are validated centrally
in Config at construction and after env/file layering, following the same
precedent as log_format (issue #119) and the horizon knobs (issue #92). An
out-of-range value is a configuration error that aborts startup loudly with
ValueError instead of running silently misconfigured. In particular
stagnation_min_novelty must be within (0.0, 1.0]; 0.0 is rejected
because the fire condition novel_share < min_novelty would then be
unreachable (a share is always >= 0), i.e. a silently disabled detector. Use
a small positive floor such as 0.01 when you need near-total staleness:
with the default window of 50 the share moves in steps of 0.02, so 0.01
fires precisely when nothing in the window was new. This means
Monitor.default() may raise ValueError during construction when handed an
invalid config; runtime detection stays fail-open, only broken startup config
fails.
SideEffectGuardDetector(config=cfg) watches steps the host marked as
non-idempotent (StepEvent.side_effect=True: a payment, a send, a deploy)
and fires on the second identical occurrence within one episode: same
(tool_name, action_signature) pair counted per episode_id. With the
default side_effect_allowed_repeats=1 a repeated charge escalates
immediately at score 0.9, which routes as critical severity. Trigger:
side_effect_duplicate. That string is API: CONTINUUM's policy table maps
it to ABORT plus immediate reconcile.
Deliberately stricter than LoopDetector, and different in three ways:
- Scope: the loop detector watches a sliding window of arbitrary signatures for wasted-work repetition; this guard watches only host-declared side-effect steps, where repetition is itself the incident.
- Threshold: the loop detector needs
repeat_thresholdhits (default 3) inside its window before saying anything; here one repeat is already the finding. - Edge: the loop detector re-arms when its window drains; this guard
fires exactly once per
(episode_id, tool_name, action_signature)key and stays quiet for the rest of the episode. A repeated payment must not alert-spam while the agent keeps making it worse; recovery isend_episode()territory. Different-argument retries produce different signatures and never fire here (that shape belongs to the loop/stall modes).
How hosts mark steps: adapters forward the flag mechanically and never
invent it. Direct-call paths carry an explicit flag: the raw adapter's
step(..., side_effect=True), observe_openai_call(...),
observe_anthropic_call(...), or observe_crewai_step(...). Callback
paths use a host-declared allowlist (issue #150):
from snagline.adapters.langchain_adapter import SnaglineCallbackHandler
from snagline.adapters.autogen import SnaglineAutogenHandler
from snagline.adapters.crewai import snagline_step_callback
# Host declares which tools are non-idempotent; adapter matches by tool_name
langchain_handler = SnaglineCallbackHandler(monitor, "ep", side_effect_tools={"charge_card"})
autogen_handler = SnaglineAutogenHandler(monitor, "ep", side_effect_tools={"charge_card"})
crewai_cb = snagline_step_callback(monitor, "ep", side_effect_tools={"charge_card"})Only a tool_call whose tool_name is in the allowlist becomes
side_effect=True; everything else stays False with the default
_emit(side_effect=False). Nothing is inferred from payloads and
metadata["side_effect"] is never read (rejected in #88). Pure
payload adapters (watch_graph for LangGraph, payload_to_event for
Claude Code) derive everything from framework payloads and have no flag
source, so they keep the schema default False; their limitation is
documented in ADAPTER_GUIDE. Legacy payloads written before #88 load
unchanged.
Privacy and cost, as everywhere: the detector reads the boolean, the tool
name, and the one-way signature digest, nothing else, and never touches
StepEvent.metadata. Non-marked steps cost one attribute check; marked
steps do one dict upsert. Per-episode memory grows with distinct marked
actions, never with repeats: replaying one charge a thousand times still
costs a single counter entry, and reset(episode_id) releases all of it.
Three opt-in detectors aimed at multi-day episodes. All are stdlib-only and O(1) amortized per step.
Sustained-burn CUSUM over per-step token volume (tokens_in + tokens_out),
plus an optional hard envelope: one warning at
token_budget_warn_fraction (default 80%) of episode_token_budget and a
single critical budget_breach risk at 100%. Trigger names (token_runaway,
budget_breach) are API: downstream policy layers map them by string.
Sliding-window Shannon entropy over tool-call identities, flagging both
collapse shapes documented for long-horizon agents (arXiv:2603.29231):
rote collapse below meltdown_low_entropy bits and churn above
meltdown_high_entropy bits. Thresholds were tuned against fixtures so
healthy eight-tool round-robin (~2.97 bits at window 20) stays silent
(high threshold 3.4 bits, about 10.5 tools uniform), while churn across
12+ distinct tools in one window (~3.52 bits) fires.
The completion check from arXiv:2608.02464: evaluated once at
Monitor.end_episode(), it fires when an episode's last step was an
error-free bare tool call instead of an output step. To judge at episode end,
a detector implements the duck-typed finalize(episode_id) method
(detectors.base.EpisodeFinalizer); end_episode discovers it by attribute,
so ordinary detectors are unaffected and fail-open applies as always.
Which action types count as "output" is host-specific (issue #578). The
check compares the final step's action_type against
Config.silent_abort_output_action_types, which defaults to
{"message", "plan_step"} -- the types the LangChain adapter emits for a
completed turn. Of the shipped integrations, only that one ends a healthy
episode on a member of the default set:
| integration | action type of a healthy episode's final step | in the default set? |
|---|---|---|
adapters/langchain_adapter |
plan_step (on_chain_end) or message (on_llm_end) |
yes |
adapters/claude_code |
tool_call (the Stop hook is dropped as lifecycle noise) |
no |
auto/openai, auto/anthropic |
tool_call (every LLM call is wrapped the same way) |
no |
adapters/langgraph_adapter |
node_run |
no |
adapters/crewai |
tool_call or agent_step |
no |
adapters/autogen |
agent_step |
no |
So with the stock default, enabling this detector on any integration below
the first row pages silent_abort at end-of-episode on essentially every
successful run -- the exact false-positive storm rule 5 says gets a
detector uninstalled. Set the knob to the types your host actually terminates
on, in code, from the environment, or in a config file:
Config(
silent_abort_enabled=True,
silent_abort_output_action_types=frozenset({"tool_call"}),
)SNAGLINE_SILENT_ABORT_ENABLED=1 \
SNAGLINE_SILENT_ABORT_OUTPUT_ACTION_TYPES=tool_call,node_run snagline watch ...A value that names nothing ("", a lone comma) is rejected at construction
and on resolve() rather than silently clearing the set, because an empty set
makes every episode look abandoned. The detector's own output_action_types
constructor argument still exists and still wins over the snapshot when
restoring state (issue #347); Config is simply the surface
Monitor.default() reads.
Governance-decay detection across context compactions. Motivation comes from arXiv:2606.22528, which studies how compaction (summarization, truncation, eviction) can silently drop governance constraints from an agent's context and measurably raises policy-violation rates afterwards. Their finding is cited as motivation only; SNAGLINE claims no benchmark numbers of its own for this detector.
The contract uses two adapter-defined action types that core otherwise treats opaquely:
step("compaction", metadata={"pinned": ["<sha256>", ...]}) # just compacted
step("constraint_present", metadata={"pin": "<sha256>"}) # pin re-seen
After a compaction event the host has compaction_tripwire_grace_steps
(default 3) subsequent events to re-confirm every pinned hash. Pins still
unconfirmed at the deadline produce exactly one FailureRisk(score=0.9, trigger="governance_decay") naming 16-hex prefixes of the missing pins; a
later compaction replaces the pending set and restarts the grace window.
Privacy: hashes only. The adapter hashes canonical constraint text itself,
so constraint text never reaches snagline. This detector is also the ONE
documented exception to "detectors never read metadata": it reads exactly
two keys (pinned on compaction events, pin on confirmations) and nothing
else.
Honest limits: the tripwire is inert by design wherever the host offers no compaction hooks or no way to observe constraint presence. See ADAPTER_GUIDE and FRAMEWORK_BRIDGES for what adapters must do, and what they honestly cannot.
Detectors that implement the duck-typed dump_state() / load_state(state)
pair (detectors.base.StatefulDetector; JSON-compatible data only, never
pickle) participate in Monitor.snapshot(path) / Monitor.restore(path), and Monitor-level time-axis clocks (_EpisodeClock per episode_id) are captured alongside them under "time_axis" and restored tolerantly (issue #172).
Snapshots are written atomically (tmp + os.replace). Restore is a
setup-time operation: a version or strict-composition mismatch raises rather
than failing open -- a monitor that silently starts blank is exactly the quiet
misbehavior this exists to prevent. Every shipped detector implements it
(loop, error_cascade, latency_anomaly, token_runaway, silent_abort,
meltdown, ml_ensemble, goal_drift, stagnation,
side_effect_guard, and compaction_tripwire -- the last two added by
issue #149), and DedupSink persists cooldowns when used with its default
key function. The optional ESN under ml_ensemble participates too (issue
#581): EsnCusumDetector.dump_state carries the readout fitted by fit() so
a restart keeps the healthy-run model instead of re-learning the live stream,
plus each live episode's reservoir, CUSUM accumulator, and half-finished
warm-up system.
Restart cost, stated honestly: the ESN's warm-up is warmup_steps steps
of deliberate silence, and a snapshot written with a different
reservoir_size is dropped rather than reshaped, so a restart onto a
differently-configured host costs a cold start on the ESN leg. The
deterministic detectors have no such gap.