watchdog

package
v2.9.0-dev.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 17, 2026 License: Apache-2.0 Imports: 5 Imported by: 0

Documentation

Index

Constants

View Source
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.

View Source
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 file, one RBAC denial — and below the point where a model has usually stopped gathering evidence and started composing an answer.

View Source
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.

Variables

This section is empty.

Functions

func FormatFeedback added in v2.9.0

func FormatFeedback(alerts []Alert) string

FormatFeedback renders alerts as the model-facing block --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 grep 5 times" as a user complaint will apologize instead of changing behavior. And it must carry an instruction, not a description: the whole point of routing this to the model is that a description of a loop is what it already had.

Not a trust boundary. A user prompt can contain the literal "watchdog" string, exactly as it can contain "[Inbox]" or "[Background reports]"; this block is a steering signal, and nothing downstream grants authority based on it.

Types

type Alert

type Alert struct {
	Signal   string
	Severity Severity
	Reason   string
	Guidance string
}

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 addressed to two different readers, and the split is load-bearing. Reason is operator-facing — the agent's wiring logs it verbatim, and it may name operator affordances ("/interrupt", "--max-turn-cost-usd") that only a human at a terminal can act on. Guidance is model- facing: what the *agent* should do differently on its next turn, written as an instruction with no reference to operator controls, because under --watchdog=feedback (#159) it is injected into the model's next-turn context. An unattended daemon has no operator to read Reason, so for it Guidance is the only half that does work.

Guidance is optional. A third-party Signal that leaves it empty still produces feedback — FormatFeedback falls back to Reason — but the fallback carries operator advice the model can't take, so built-in signals should always set it.

func (Alert) String

func (a Alert) String() string

String implements fmt.Stringer for Alert so log lines stay uniform. Format: "[severity] signal: reason".

type AlternatingCycleSignal added in v2.9.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 the enforce-mode backstop (#623) was an agent cycling list_agents → check_agent, and it survived both an operator "stop" and /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 a Critical alert halts the agent under --watchdog=enforce:

  • 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 and (under feedback mode) the tokens.
  • 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, grep, read another, grep 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. That is what wait_and_verify (#648) exists for, and an operator who wants the pattern anyway can drop the signal by constructing DefaultWatchdog with their own signal list.

func NewAlternatingCycleSignal added in v2.9.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 v2.9.0

func (s *AlternatingCycleSignal) Name() string

Name implements Signal.

func (*AlternatingCycleSignal) ObserveToolCall added in v2.9.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 v2.9.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 signals are Critical, so both halt under --watchdog=enforce. The failure streak is Warn: it never halts, and reaches the operator log plus — under --watchdog=feedback — the model's own next turn. Operators wanting different thresholds, or a subset, construct DefaultWatchdog directly with a custom signal list.

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 v2.9.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 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 halts 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 the operator- visible-but-not-action-blocking level; Critical marks a runaway the agent should act on — under --watchdog=enforce (#623) a Critical alert halts the agent (refuses further turns until the operator resets), while warn mode logs it like any other alert.

const (
	SeverityWarn     Severity = "warn"
	SeverityCritical Severity = "critical"
)

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 v2.9.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

type ToolCall struct {
	Name string
	Args string
}

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 v2.9.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, not Critical, and that is a decision rather than an oversight. Under --watchdog=enforce a Critical alert halts the agent, and since #642 enforce is the default for unattended runs; 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 (under --watchdog=feedback) the model's own next turn. Runaway *behavior* is already Critical via the loop detectors.

func NewToolFailureStreakSignal added in v2.9.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 v2.9.0

func (s *ToolFailureStreakSignal) Name() string

Name implements Signal.

func (*ToolFailureStreakSignal) ObserveToolCall added in v2.9.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 v2.9.0

func (s *ToolFailureStreakSignal) ObserveToolResult(tr ToolResult) *Alert

ObserveToolResult implements SignalResultObserver.

func (*ToolFailureStreakSignal) Reset added in v2.9.0

func (s *ToolFailureStreakSignal) Reset()

Reset implements Signal.

type ToolResult added in v2.9.0

type ToolResult struct {
	Name  string
	Error string
}

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 agent wiring flattens that to this field so the watchdog never has to know the provider's response shape.

func (ToolResult) Failed added in v2.9.0

func (r ToolResult) Failed() bool

Failed reports whether the call errored.

type ToolResultObserver added in v2.9.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 agent 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 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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL