Documentation
¶
Index ¶
- Constants
- func FormatFeedback(alerts []Alert) string
- func IsTripped(err error) bool
- func ObserveEvent(w Watchdog, ev *session.Event, seen map[string]struct{}) bool
- func ObserveToolResults(w Watchdog, ev *session.Event, seen map[string]struct{}) bool
- func Tap(events iter.Seq2[*session.Event, error], w Watchdog, onAlert func(Alert)) iter.Seq2[*session.Event, error]
- type Alert
- type AlternatingCycleSignal
- type DefaultWatchdog
- type Enforcer
- type Feedback
- type Mode
- type RepeatedToolCallSignal
- type Severity
- type Signal
- type SignalResultObserver
- type ToolCall
- type ToolFailureStreakSignal
- type ToolResult
- type ToolResultObserver
- type TrippedError
- type Watchdog
Constants ¶
const ( DefaultCycleMaxPeriod = 4 DefaultCycleRepeats = 3 )
Default tuning for AlternatingCycleSignal. Exported so an operator building a custom signal list can see what they are deviating from.
const DefaultFailureStreak = 3
DefaultFailureStreak is the number of consecutive failed tool calls that trips ToolFailureStreakSignal. Three is chosen to sit above ordinary exploration — a 404, a missing resource, one RBAC denial — and below the point where a model has usually stopped gathering evidence and started composing an answer.
const DefaultMode = ModeFeedback
DefaultMode is the rung a host picks when nothing declared one — no flag, no bundle field, no explicit argument.
This is a different question from ParseMode's, and the two answers differ on purpose. ParseMode is handed a value and asked what it means; the safe reading of an empty value is the bottom rung, because a parse must never arm a kill switch out of nothing. A host choosing a default is instead asked what to do when nobody chose, and for mast the honest answer is not warn: every mast run is unattended, so a warning goes to a log nobody is tailing and warn is indistinguishable from off. Feedback routes the same observation to the one party present at the scene — the model — and its false-positive cost is a paragraph, not a halted workload. Hosts that want the halt say so.
const FeedbackHeader = "[watchdog]"
FeedbackHeader opens the block FormatFeedback renders. Exported so hosts, tests and transcript tooling can find the boundary without re-typing the literal.
const MaxPendingFeedback = 4
MaxPendingFeedback caps the queue of alerts awaiting injection. The queue drains on every turn, so it only grows when a host observes turns without starting new ones; the bound keeps that case from becoming an ever-growing prompt prefix. The oldest are dropped, because the newest observation describes the behavior the model is about to repeat.
Variables ¶
This section is empty.
Functions ¶
func FormatFeedback ¶ added in v0.4.0
FormatFeedback renders alerts as the model-facing block that --watchdog=feedback prepends to the next turn's prompt. Returns "" for an empty slice so callers can skip the prepend cheaply.
Two things the wording has to do. It must be unmistakably about the model's *own last turn* rather than a message from the user — a model that reads "you called kubectl_get 5 times" as a user complaint will apologize instead of changing behavior. And it must carry an instruction rather than a description: a description of the loop is precisely what the model already had.
Not a trust boundary. A user prompt can contain the literal "watchdog" string exactly as it can contain any other marker; this block is steering, and nothing downstream grants authority based on it.
func IsTripped ¶ added in v0.4.0
IsTripped reports whether err is a watchdog halt. Uses errors.As, so a caller may wrap it with turn context without losing the classification.
func ObserveEvent ¶
ObserveEvent walks ev's content parts and feeds any function-call parts to w. Args are JSON-serialized so the watchdog's literal- string-compare detector has stable input — Go's map iteration order would otherwise make logically-identical calls compare unequal.
seen is the per-turn dedup set (#363): ADK's streaming aggregator can re-emit the same FunctionCall part across more than one event (an intermediate aggregate plus the final — the same duplication runner/events.go dedups for display). Calls carrying an ID dedup on it (a re-emitted part keeps its ID; a legitimate parallel call with identical args gets a fresh one); ID-less calls fall back to name+args, which also collapses same-args parallel calls within ONE turn — acceptable, since the watchdog's runaway signal is repetition ACROSS turns and the set resets each turn. Callers create one seen map per turn and pass it to every ObserveEvent call within that turn.
Best-effort: if a part's args don't JSON-marshal cleanly we fall back to a recognizable placeholder; the alternative would be skipping the observation entirely, which silently weakens the signal. Better to compare on the placeholder than miss observations. Reports whether any observation actually landed, which is what gates Tap's in-turn drain: a signal cannot newly trip without one.
func ObserveToolResults ¶ added in v0.4.0
ObserveToolResults walks ev's content parts and feeds any function-response parts to w, when it implements the optional ToolResultObserver extension (#639). A watchdog that only counts calls is left alone.
Success vs failure follows ADK's convention: a tool error is a reserved "error" key inside FunctionResponse.Response. Flattening it here means the watchdog never has to know a provider's response shape, and one place decides what "failed" means.
Shares the per-turn dedup set with ObserveEvent, under a distinct key prefix — the same streaming aggregator that re-emits a FunctionCall part re-emits its FunctionResponse, and a double-counted failure would trip the streak signal at half its threshold. A response with no ID falls back to name+error, which collapses same-error parallel calls within one turn; that is the safe direction to be wrong in, since undercounting delays an advisory alert while overcounting fires it on work that was fine.
func Tap ¶
func Tap(events iter.Seq2[*session.Event, error], w Watchdog, onAlert func(Alert)) iter.Seq2[*session.Event, error]
Tap wraps one turn's runner event stream with watchdog observation. It creates its own per-turn dedup set (#363), feeds every FunctionCall part — and every FunctionResponse part, when w observes outcomes — to w as events pass through, and drains w.Check() into onAlert. Tap does NOT call w.Reset() — per-turn Reset would wipe the signals' run state, and cross-turn repetition is exactly what the watchdog exists to count. Reset stays a caller decision at logical session boundaries.
Alerts drain twice over, and the first one is the one that matters.
- In-turn, as soon as an observation lands. A loop *inside* one turn is the shape mast's tool-calling flow actually produces: the model emits a dispatch or MCP call, the flow runs it and calls the model again, all within a single Run. A turn-boundary drain never fires on that at all while it is happening, so the alert an operator needs during the incident arrives after it — or, if the turn never ends, never. Gated on a fresh observation because a signal cannot newly trip without one, and a turn emits far more text than tool calls. This is also what lets an enforcing caller cancel the turn in flight (see enforce.go).
- After the stream ends, unconditionally, so a signal that tripped on the last observed event doesn't leak into the next turn. The post-turn drain is deferred, so it runs even when the consumer stops consuming early — the same guarantee core-agent's wrapWithCleanup gives its post-turn hooks.
Alerts are discarded when onAlert is nil, but still pulled. Wrap each turn's stream with a fresh Tap; reusing one across turns would defeat the per-turn scoping of the dedup set.
Types ¶
type Alert ¶
Alert is what a triggered signal returns. Signal is the stable string ID the rest of the system can dispatch on (future "auto" mode picks behavior per signal).
Reason and Guidance are the same observation written for two different readers, and the split matters because the readers can do different things about it. Reason is operator-facing — the agent's wiring logs it verbatim and the attach guardrail endpoint serves it — so it names operator affordances: the interrupt endpoint, the budget ceiling, the reset. Guidance is model-facing: it is what FormatFeedback injects into the session's next prompt under ModeFeedback, so it says what the model itself can do about the pattern and names no affordance the model does not have. Telling a looping model to "POST /sessions/{id}/interrupt" is at best noise and at worst an invitation to hallucinate a tool call for it.
Guidance is optional. A signal that leaves it empty still feeds — FormatFeedback falls back to Reason — because a third-party signal silently producing no feedback would be a worse failure than one producing operator-flavored feedback.
type AlternatingCycleSignal ¶ added in v0.4.0
type AlternatingCycleSignal struct {
// MaxPeriod is the longest cycle considered. Periods start at 2 —
// period 1 is a consecutive repeat, which RepeatedToolCallSignal
// owns.
MaxPeriod int
// Cycles is how many consecutive repetitions of the block are
// required before the signal trips.
Cycles int
// contains filtered or unexported fields
}
AlternatingCycleSignal trips when the agent repeats the same short *sequence* of tool calls over and over — a → b → a → b → a → b (#649).
This is the evasion the consecutive-repeat detector named in its own docstring and could not catch: "consecutive" means a run of one call, so any loop with a second call wedged in it reads as varied activity. The shape is not hypothetical. The live UAT that motivated upstream's enforce-mode backstop (#623) was an agent cycling list_agents → check_agent, and it survived both an operator "stop" and an interrupt.
Detection is a period scan over the recent call history: for each period p in 2..MaxPeriod, the last p*Cycles observations trip the signal when they consist of Cycles byte-identical blocks of length p. Keys are canonicalized (canonicalArgs), so a cycle that alternates "./main.go" with "main.go" still reads as one call — but unlike the repeat detector this cannot use the pairwise path-suffix relation, which is not transitive and therefore cannot key a history buffer.
Two deliberate limits on false positives, because this alert is meant to be actionable rather than ambient:
- A block whose entries are all the same call is skipped. It is a plain repeat, already the other signal's job, and alerting twice for one pattern doubles the noise.
- Cycles defaults to 3, i.e. six calls for the a→b→a→b→a→b shape. Two passes through a sequence is normal work — read a file, list pods, read another, list again. Three passes with byte-identical arguments each time is not: nothing in the inputs changed, so nothing in the results can have.
A polling loop written as alternating tool calls is the known false positive, and it is a shape mast's own workloads produce — a scheduler-driven daemon watching a rollout settle looks exactly like this. An operator who wants the pattern anyway drops the signal by constructing DefaultWatchdog with their own signal list — which is the right answer for that workload, because the signal is Critical and the cost of the false positive therefore scales with the posture: one log line under warn, a paragraph of unwanted steering under feedback, a halted rollout watch under enforce.
func NewAlternatingCycleSignal ¶ added in v0.4.0
func NewAlternatingCycleSignal(maxPeriod, cycles int) *AlternatingCycleSignal
NewAlternatingCycleSignal constructs the cycle detector. maxPeriod below 2 is clamped to 2 (period 1 is the repeat detector's job) and cycles below 2 is clamped to 2 (a single pass through a sequence is not a cycle).
func (*AlternatingCycleSignal) Name ¶ added in v0.4.0
func (s *AlternatingCycleSignal) Name() string
Name implements Signal.
func (*AlternatingCycleSignal) ObserveToolCall ¶ added in v0.4.0
func (s *AlternatingCycleSignal) ObserveToolCall(tc ToolCall) *Alert
ObserveToolCall implements Signal. Appends the call to a bounded history and reports the shortest cycle covering the tail.
func (*AlternatingCycleSignal) Reset ¶ added in v0.4.0
func (s *AlternatingCycleSignal) Reset()
Reset implements Signal.
type DefaultWatchdog ¶
type DefaultWatchdog struct {
// contains filtered or unexported fields
}
DefaultWatchdog is the package-default implementation. Fans observations across the configured signals; Check collects alerts from each.
func NewDefaultWatchdog ¶
func NewDefaultWatchdog() *DefaultWatchdog
NewDefaultWatchdog returns a DefaultWatchdog wired with the default signal set:
- RepeatedToolCall (threshold 5): 5 consecutive calls to the same tool with path-canonicalized-identical args.
- AlternatingCycle (period ≤ 4, 3 laps): the same short sequence of calls repeated three times — the a → b → a → b shape the repeat detector structurally cannot see (#649).
- ToolFailureStreak (3 in a row): every call erroring with none succeeding in between, i.e. an agent with no verified evidence about anything (#639).
The two loop detectors are Critical and the failure streak is Warn (see each signal's docstring for why). Operators wanting different thresholds, or a subset, construct DefaultWatchdog directly with a custom signal list — the cycle detector is the one most likely to be dropped, on a workload whose normal shape is a polling loop.
func (*DefaultWatchdog) Check ¶
func (w *DefaultWatchdog) Check() []Alert
Check returns any alerts that accumulated since the last Check and resets the buffer. Returns nil (not an empty slice) when no alerts are pending — lets the caller skip work cheaply.
func (*DefaultWatchdog) ObserveToolCall ¶
func (w *DefaultWatchdog) ObserveToolCall(tc ToolCall)
ObserveToolCall fans the observation across every wired signal.
func (*DefaultWatchdog) ObserveToolResult ¶ added in v0.4.0
func (w *DefaultWatchdog) ObserveToolResult(tr ToolResult)
ObserveToolResult fans a tool outcome across every wired signal that implements SignalResultObserver. Implements ToolResultObserver.
func (*DefaultWatchdog) Reset ¶
func (w *DefaultWatchdog) Reset()
Reset clears alerts + every signal's state. Called on logical session boundaries (e.g. operator-initiated /clear).
type Enforcer ¶ added in v0.4.0
type Enforcer struct {
// contains filtered or unexported fields
}
Enforcer holds one session's halt state.
One per session, alongside the Watchdog itself: the signals count across turns, and so does the trip. A daemon-global enforcer would let one runaway session refuse turns for every other one.
Safe for concurrent use. The alert path runs from the event tap while an attach handler can be reading Tripped or calling Reset.
func NewEnforcer ¶ added in v0.4.0
NewEnforcer returns an Enforcer in the given posture. The zero Mode is ModeWarn, so a caller that forgets to set one gets the harmless default rather than an armed kill switch.
remedy is appended to the halt reason and answers the only question an operator reading it has: how do I clear this? It is the caller's to supply because the answer is host-specific — the daemon names its reset endpoint, a one-shot has none to name. Empty is fine.
func (*Enforcer) Adopt ¶ added in v0.4.0
Adopt restores a halt this Enforcer did not observe — the trip a previous process recorded before it died — and reports whether it took effect.
Signal and reason are carried over verbatim rather than reconstructed, so a restored halt says what the original said. An operator reading "watchdog halted this session (tool_failure_streak)" after a pod roll is reading the sentence the halt was written with, not a paraphrase of it.
Two refusals, both deliberate:
Adopt is a no-op unless the current mode enforces. The persisted trip is history; the mode is configuration, and configuration still wins. A deployment that has since been dialed back to feedback — or that restarted with a different bundle — must not inherit a halt it would no longer produce, or a posture change becomes unreachable: the process refuses turns because of a trip only enforce mode could have made, and only a turn could clear.
Adopt is also a no-op on an already-tripped Enforcer, so a restore racing a live halt cannot overwrite the fresher reason with the stored one.
func (*Enforcer) Observe ¶ added in v0.4.0
Observe records one alert and reports whether it halts the session.
True exactly once per trip: the first Critical alert under ModeEnforce. Later alerts on an already-tripped session return false so the caller cancels a turn once rather than on every remaining event — the same idempotence upstream's maybeTripWatchdog keeps. Non-Critical alerts never halt, whatever the mode.
func (*Enforcer) Preflight ¶ added in v0.4.0
Preflight returns a non-nil *TrippedError when the session is halted. Callers run it at the top of a turn, before any model call: the refusal has to be structural, or an auto-resume or a scheduled re-fire of the halted session re-drives the very loop that tripped it.
func (*Enforcer) Reset ¶ added in v0.4.0
func (e *Enforcer) Reset()
Reset clears the halt. Safe when nothing tripped.
It does not reset the Watchdog's signals — the caller owns both and resets them together, because clearing the trip while the signal still holds a completed run would re-halt on the next call.
type Feedback ¶ added in v0.4.0
type Feedback struct {
// contains filtered or unexported fields
}
Feedback holds one session's queue of observations awaiting delivery to the model.
One per session, alongside the Watchdog and the Enforcer: an observation about one session's loop is meaningless prepended to another's prompt.
Safe for concurrent use. Alerts arrive from the event tap while the turn path may be draining the queue.
func NewFeedback ¶ added in v0.4.0
NewFeedback returns a queue in the given posture. The zero Mode is ModeWarn, which queues nothing.
The mode lives here rather than at the call site so the "queue nothing below feedback" rule holds for every caller: were a host to queue under warn and only gate the injection, flipping a long-running deployment to feedback would deliver a backlog of observations about turns that ended hours ago.
func (*Feedback) Drain ¶ added in v0.4.0
Drain returns the queued alerts and empties the queue. Returns nil when nothing is pending.
Draining on read rather than on turn success means an observation is delivered exactly once even if the turn it lands in fails. Losing it in that case is the right trade: by the time a retry lands the signal describes behavior several turns back, and a block that re-appears every turn until some turn succeeds is a prompt leak.
type Mode ¶ added in v0.4.0
type Mode string
Mode is the watchdog's posture: what the deployment does when a signal trips.
The postures are a ladder, and each rung includes the one below it: warn logs, feedback also tells the model, enforce also stops it.
Detection is identical in all three. Every signal observes, tallies, and alerts the same way — the mode only decides the reaction, which is why a workload can be switched between them without changing what gets found.
const ( // ModeWarn logs the alert and lets the turn run. The bottom rung, and // what an empty value parses to — but not what a host picks when // nobody chose; see DefaultMode for why those are different // questions. ModeWarn Mode = "warn" // ModeFeedback warns, and additionally routes each alert's Guidance // into the session's next prompt. The party that can stop making the // looping call is the model making it, and under warn alone it is // the one party never told. ModeFeedback Mode = "feedback" // ModeEnforce feeds back, and additionally halts on a Critical // alert: the turn in flight is cancelled and every subsequent turn // is refused until an operator resets, the same contract the budget // ceiling keeps. // // Enforce implies feedback on purpose. Without it, the turn after a // reset starts with the model knowing nothing about why it was // stopped, which is a treadmill: loop, halt, reset, loop. ModeEnforce Mode = "enforce" )
func ParseMode ¶ added in v0.4.0
ParseMode validates an operator-supplied posture. An empty string is ModeWarn — an unset flag must not silently arm a kill switch.
type RepeatedToolCallSignal ¶
type RepeatedToolCallSignal struct {
Threshold int
// contains filtered or unexported fields
}
RepeatedToolCallSignal trips when the same (name, args) tool call appears Threshold times consecutively. Catches the read_file loop pattern from issue #144 and similar runaway-tool-call shapes.
"Consecutive" is the key word: a → b → a → b → a doesn't trip (no run of identical calls), but a → a → a → a → a does. This matches operator intuition ("the agent is stuck on the same thing") without flagging legitimate patterns like alternating-tool exploration loops.
Args comparison is path-canonicalized (#649, see canonical.go), not literal-string as in v1: "main.go", "./main.go" and "/workspace/main.go" are one call, because an agent re-reading one file under three spellings is as stuck as one re-reading it under the same spelling. Everything else still compares exactly — a detector that generalizes too eagerly flags legitimate work.
func NewRepeatedToolCallSignal ¶
func NewRepeatedToolCallSignal(threshold int) *RepeatedToolCallSignal
NewRepeatedToolCallSignal constructs a signal with the given threshold. Threshold must be ≥ 2 (a "repeated call" requires at least two of the same in a row); values < 2 are clamped to 2 to avoid the degenerate case where every tool call trips the signal.
func (*RepeatedToolCallSignal) Name ¶
func (s *RepeatedToolCallSignal) Name() string
Name implements Signal.
func (*RepeatedToolCallSignal) ObserveToolCall ¶
func (s *RepeatedToolCallSignal) ObserveToolCall(tc ToolCall) *Alert
ObserveToolCall implements Signal. Tracks the running count of consecutive identical calls; emits an alert when count reaches Threshold. Returns nil on subsequent observations within the same run (already-tripped guard) so we don't re-emit on every extra call — operators want one notice per stuck pattern, not one per tool call past the threshold.
func (*RepeatedToolCallSignal) Reset ¶
func (s *RepeatedToolCallSignal) Reset()
Reset implements Signal.
type Severity ¶
type Severity string
Severity classifies the urgency of an alert. Warn is operator- visible but never action-blocking. Critical marks a runaway the deployment should act on: under ModeEnforce a Critical alert halts the turn in flight and refuses the next one until an operator resets (see enforce.go), while under ModeWarn it is logged like any other alert.
Severity is an intrinsic property of the pattern, not of the wiring. A tool loop is a runaway whether or not anything is configured to stop it, so the signal reports Critical either way and the mode decides the reaction.
type Signal ¶
type Signal interface {
// Name returns the stable signal ID used in Alert.Signal.
Name() string
// ObserveToolCall updates the signal's internal state with one
// tool invocation. Returning a non-nil Alert means the signal
// tripped on this observation; DefaultWatchdog appends it to
// the pending-alerts buffer.
ObserveToolCall(ToolCall) *Alert
// Reset clears the signal's state. Called from
// DefaultWatchdog.Reset.
Reset()
}
Signal is the per-detector interface inside DefaultWatchdog. Each signal owns its own state and decides when to emit an alert. Implementations must be safe to call serially from DefaultWatchdog (which holds a mutex across observations); they do NOT need to be concurrency-safe themselves.
Adding a new signal: implement Signal, append to NewDefaultWatchdog's signal list (or to a constructor variant). No changes to DefaultWatchdog itself.
type SignalResultObserver ¶ added in v0.4.0
type SignalResultObserver interface {
ObserveToolResult(ToolResult) *Alert
}
SignalResultObserver is the same extension one level down, for signals inside DefaultWatchdog. A Signal that doesn't implement it simply never sees results.
type ToolCall ¶
ToolCall is the per-tool-call observation the watchdog needs. Name is the canonical tool name (e.g. "read_file", "mcp.gke.list_clusters"). Args is the JSON-serialized argument blob, passed through as the caller produced it: the detectors canonicalize path-shaped values themselves (#649, canonical.go) rather than requiring every caller to agree on a normal form, and a third-party Watchdog implementation still sees the raw args.
type ToolFailureStreakSignal ¶ added in v0.4.0
type ToolFailureStreakSignal struct {
Threshold int
// contains filtered or unexported fields
}
ToolFailureStreakSignal trips when Threshold tool calls in a row all return errors. Any successful call resets the run — the point is "this agent currently has no verified evidence," and one success is evidence.
Severity is Warn, and stays Warn when mast grows a posture that acts on Critical. Halting a daemon three denials into a legitimate RBAC probe would make the backstop the outage. A failure streak is an evidence problem, so it goes where evidence problems belong — the operator log, and the session's guardrail surface. Runaway *behavior* is the loop detectors' business.
func NewToolFailureStreakSignal ¶ added in v0.4.0
func NewToolFailureStreakSignal(threshold int) *ToolFailureStreakSignal
NewToolFailureStreakSignal constructs the signal. Threshold below 2 is clamped to 2: a single failed call is an ordinary event, not a signal, and threshold 1 would alert on every one of them.
func (*ToolFailureStreakSignal) Name ¶ added in v0.4.0
func (s *ToolFailureStreakSignal) Name() string
Name implements Signal.
func (*ToolFailureStreakSignal) ObserveToolCall ¶ added in v0.4.0
func (s *ToolFailureStreakSignal) ObserveToolCall(ToolCall) *Alert
ObserveToolCall implements Signal. Calls carry no outcome, so this signal ignores them; the interface requires the method because DefaultWatchdog fans every call across every signal.
func (*ToolFailureStreakSignal) ObserveToolResult ¶ added in v0.4.0
func (s *ToolFailureStreakSignal) ObserveToolResult(tr ToolResult) *Alert
ObserveToolResult implements SignalResultObserver.
func (*ToolFailureStreakSignal) Reset ¶ added in v0.4.0
func (s *ToolFailureStreakSignal) Reset()
Reset implements Signal.
type ToolResult ¶ added in v0.4.0
ToolResult is the outcome half of a tool invocation. Error is the tool's error text, empty for success — the ADK convention is a reserved "error" key inside FunctionResponse.Response, and the session-event bridge flattens that to this field so the watchdog never has to know the provider's response shape.
func (ToolResult) Failed ¶ added in v0.4.0
func (r ToolResult) Failed() bool
Failed reports whether the call errored.
type ToolResultObserver ¶ added in v0.4.0
type ToolResultObserver interface {
ObserveToolResult(ToolResult)
}
ToolResultObserver is the optional half of Watchdog: an implementation that also wants to see tool *outcomes* implements it, and the bridge feeds results through a type assertion.
Deliberately not folded into Watchdog. That interface is documented as a plug-in point ("consumers can plug in their own implementation"), so widening it would break every third-party watchdog at a minor version to add one signal — and a custom watchdog that only counts calls stays perfectly valid.
type TrippedError ¶ added in v0.4.0
type TrippedError struct {
// Signal is the alert that halted the session.
Signal string
// Reason is the operator-facing text, alert included.
Reason string
}
TrippedError is what a caller returns from a turn the watchdog halted, and what Preflight returns for every turn after it. A distinct type so a host can tell "an operator must reset this" apart from a failure worth retrying: retrying a watchdog trip re-drives the loop that caused it, which is the exact failure enforce mode exists to break.
func (*TrippedError) Error ¶ added in v0.4.0
func (e *TrippedError) Error() string
type Watchdog ¶
type Watchdog interface {
// ObserveToolCall records one tool invocation. Called by the
// agent's event-tap as tool calls stream by; safe to call from
// any goroutine.
ObserveToolCall(ToolCall)
// Check returns alerts triggered since the last Check call and
// resets the per-call alert buffer. Returns nil when no signal
// has tripped. Typically called from the agent's post-turn
// hook; an alert returned here is "for the turn just ended."
Check() []Alert
// Reset clears all accumulated state. Called when the agent
// resets (e.g. via a hypothetical /clear that clears history)
// so signals don't carry across a logical session boundary.
Reset()
}
Watchdog observes per-turn telemetry and returns any alerts that triggered during observation. Implementations must be safe for concurrent use — the agent calls Observe* methods from the streaming event handler and Check from the post-turn hook; concurrency is bounded but real.
The interface is intentionally narrow for v1: just tool-call observation + alert reporting. Richer telemetry (turn timing, per-turn cost delta, files-touched diff) can be added as additional Observe* methods as new signals need them.