Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ file and uses it as the GitHub Release body, so keep each version's notes here.
- Auto-updating contributors wall (scheduled workflow + `contributors.json`).
- PyPI auto-publish workflow on `v*` tags and a PR test/coverage workflow.

### Changed
- Refactor (`#500`): Inlined `SilenceBaseline` attributes into `SilentFailureDetector`. Reduces file complexity and removes the redundant dataclass abstraction (also addresses `#478` and `#498`).

## [0.2.0-preview] - 2026-05-27

### Added
Expand Down
52 changes: 52 additions & 0 deletions agentwatch/core/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ class EventType(str, Enum):
CONFIDENCE_SCORE = "confidence.score"
ANOMALY_DETECTED = "anomaly.detected"

# Reasoning auditor
STYLE_FINGERPRINT_COMPUTED = "reasoning.style_fingerprint"
STYLE_SWAP_DETECTED = "reasoning.style_swap"

# Generic
CUSTOM = "custom"

Expand Down Expand Up @@ -240,6 +244,54 @@ class ConfidenceData(BaseModel):
explanation: str | None = None


class ReasoningStyleFingerprint(BaseModel):
"""Per-session stylistic profile of the model's reasoning output (RSN-008).

Captures *observable* artifacts of the reasoning style — not chain-of-thought
contents — so it can be used to detect silent mid-session model swaps
(e.g. a provider rolling out a new model version without changing the
exposed model name).
"""

mean_planner_tokens: float = 0.0
lex_diversity: float = 0.0
tools_per_plan: float = 0.0
punctuation_rate: float = 0.0
sample_size: int = 0 # number of PLANNER_OUTPUT events used

@classmethod
def from_dataclass(cls, other: object) -> ReasoningStyleFingerprint:
"""Adapt the dataclass form in ``agentwatch.reasoning.fingerprint``.

Returns:
A pydantic instance mirroring the dataclass fields.
"""
return cls(
mean_planner_tokens=float(getattr(other, "mean_planner_tokens", 0.0)),
lex_diversity=float(getattr(other, "lex_diversity", 0.0)),
tools_per_plan=float(getattr(other, "tools_per_plan", 0.0)),
punctuation_rate=float(getattr(other, "punctuation_rate", 0.0)),
sample_size=int(getattr(other, "sample_size", 0)),
)


class StyleSwapAlert(BaseModel):
"""Alert raised when mid-session fingerprint drift suggests a model swap.

Distance is the Euclidean-style distance between the first-half and
second-half fingerprints, normalized to be roughly comparable across
sessions with different sizes.
"""

session_id: str
detected: bool
distance: float
threshold: float
first_half: ReasoningStyleFingerprint
second_half: ReasoningStyleFingerprint
reason: str | None = None


class AgentMessageData(BaseModel):
"""Inter-agent message in a multi-agent workflow."""

Expand Down
99 changes: 98 additions & 1 deletion agentwatch/reasoning/auditor.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,17 @@
from statistics import mean
from typing import Any, cast

from agentwatch.core.schema import AgentEvent, EventType
from agentwatch.core.schema import (
AgentEvent,
EventType,
ReasoningStyleFingerprint,
StyleSwapAlert,
)
from agentwatch.reasoning.fingerprint import (
StyleFingerprint,
detect_mid_session_change,
fingerprint,
)

JudgeCallback = Callable[[str, AgentEvent], Awaitable[dict[str, Any]]]

Expand Down Expand Up @@ -50,6 +60,22 @@ def to_dict(self) -> dict[str, object]:
}


@dataclass
class FingerprintReport:
"""Bundle of style-fingerprint + swap-detection results for a session."""

session_id: str
fingerprint: ReasoningStyleFingerprint
swap_alert: StyleSwapAlert

def to_dict(self) -> dict[str, object]:
return {
"session_id": self.session_id,
"fingerprint": self.fingerprint.model_dump(),
"swap_alert": self.swap_alert.model_dump(),
}


class ReasoningAuditor:
"""
Audits observable reasoning artifacts.
Expand Down Expand Up @@ -100,6 +126,77 @@ async def audit_step(self, step_index: int, event: AgentEvent) -> StepAudit:
)
return self._heuristic_audit(step_index, event)

def fingerprint_session(
self,
events: list[AgentEvent],
*,
distance_threshold: float = 1.0,
split_ratio: float = 0.5,
) -> FingerprintReport:
"""Compute the session style fingerprint and detect mid-session swaps.

Args:
events: All events recorded for the session (any timeline order is
fine — the mid-session split is positional on the list you pass).
distance_threshold: Euclidean-style distance above which a fingerprint
delta is reported as a probable mid-session model swap.
split_ratio: Where to split the events list into halves when
comparing the first vs second half.

Returns:
A :class:`FingerprintReport` containing both the full-session
fingerprint and the swap-detection alert.
"""
full = fingerprint(events)
detected, distance = detect_mid_session_change(
events,
split_ratio=split_ratio,
distance_threshold=distance_threshold,
)
# Compute both halves for diagnostic context, even when we cannot
# actually compare them — this lets downstream consumers see *why*
# detection returned ``False``.
cut = int(len(events) * split_ratio)
first, second = events[:cut], events[cut:]
first_fp = self._pydantic_fingerprint(fingerprint(first))
second_fp = self._pydantic_fingerprint(fingerprint(second))
Comment on lines +161 to +162

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Half fingerprints always report sample_size == 0.

first_fp/second_fp are built via _pydantic_fingerprint(fingerprint(first)) without passing sample_size, unlike the full-session fingerprint (line 173). Consumers of StyleSwapAlert.first_half/second_half (e.g. dashboards) will always see sample_size: 0 for both halves, even though the field is documented as "number of PLANNER_OUTPUT events used". See the proposed fix in the adjacent comment on lines 150-186, which addresses this alongside the reason ambiguity.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agentwatch/reasoning/auditor.py` around lines 161 - 162, The half-session
fingerprint construction in auditor.py is missing the event count, so
first_half/second_half always end up with sample_size set to 0. Update the
fingerprint assembly in the code that builds first_fp and second_fp inside the
StyleSwapAlert path to pass the same PLANNER_OUTPUT event count used by the
full-session fingerprint, using the existing fingerprint() and
_pydantic_fingerprint() flow. Keep the fix aligned with the surrounding
alert-building logic so dashboards consuming StyleSwapAlert.first_half and
StyleSwapAlert.second_half see the correct sample_size value.

session_id = events[0].session_id if events else ""
reason: str | None = None
if not detected:
if len(events) < 6:
reason = "insufficient_events"
elif distance == 0.0:
reason = "insufficient_planner_signal"
else:
reason = "below_threshold"
plans_count = sum(1 for e in events if e.event_type == EventType.PLANNER_OUTPUT)
fp_pydantic = self._pydantic_fingerprint(full, sample_size=plans_count)
return FingerprintReport(
session_id=session_id,
fingerprint=fp_pydantic,
swap_alert=StyleSwapAlert(
session_id=session_id,
detected=detected,
distance=round(distance, 4),
threshold=distance_threshold,
first_half=first_fp,
second_half=second_fp,
reason=reason,
),
)
Comment on lines +150 to +186

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

reason derivation conflates "no planner signal" with a genuine zero-distance "below threshold".

detect_mid_session_change short-circuits to (False, 0.0) both when a half lacks PLANNER_OUTPUT events and when two halves happen to be identical (real distance of 0.0). Here, elif distance == 0.0: reason = "insufficient_planner_signal" treats both cases identically, so a session with genuinely identical planner style across halves is mislabeled as "insufficient signal" instead of "below_threshold". The test test_fingerprint_session_no_swap_detected_consistent_plans even asserts reason in {"insufficient_planner_signal", "below_threshold"}, acknowledging this ambiguity rather than resolving it. Since dashboards will render this reason field, an inaccurate label undermines the diagnostic value this PR is meant to add.

This also duplicates fingerprint(first)/fingerprint(second) computation that detect_mid_session_change already performs internally.

A low-effort fix: populate sample_size on the half fingerprints (see next comment) and use that as the reliable "no planner output" signal instead of proxying via distance == 0.0.

🐛 Proposed fix
         cut = int(len(events) * split_ratio)
         first, second = events[:cut], events[cut:]
-        first_fp = self._pydantic_fingerprint(fingerprint(first))
-        second_fp = self._pydantic_fingerprint(fingerprint(second))
+        first_plans = sum(1 for e in first if e.event_type == EventType.PLANNER_OUTPUT)
+        second_plans = sum(1 for e in second if e.event_type == EventType.PLANNER_OUTPUT)
+        first_fp = self._pydantic_fingerprint(fingerprint(first), sample_size=first_plans)
+        second_fp = self._pydantic_fingerprint(fingerprint(second), sample_size=second_plans)
         session_id = events[0].session_id if events else ""
         reason: str | None = None
         if not detected:
             if len(events) < 6:
                 reason = "insufficient_events"
-            elif distance == 0.0:
+            elif first_plans == 0 or second_plans == 0:
                 reason = "insufficient_planner_signal"
             else:
                 reason = "below_threshold"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
full = fingerprint(events)
detected, distance = detect_mid_session_change(
events,
split_ratio=split_ratio,
distance_threshold=distance_threshold,
)
# Compute both halves for diagnostic context, even when we cannot
# actually compare them — this lets downstream consumers see *why*
# detection returned ``False``.
cut = int(len(events) * split_ratio)
first, second = events[:cut], events[cut:]
first_fp = self._pydantic_fingerprint(fingerprint(first))
second_fp = self._pydantic_fingerprint(fingerprint(second))
session_id = events[0].session_id if events else ""
reason: str | None = None
if not detected:
if len(events) < 6:
reason = "insufficient_events"
elif distance == 0.0:
reason = "insufficient_planner_signal"
else:
reason = "below_threshold"
plans_count = sum(1 for e in events if e.event_type == EventType.PLANNER_OUTPUT)
fp_pydantic = self._pydantic_fingerprint(full, sample_size=plans_count)
return FingerprintReport(
session_id=session_id,
fingerprint=fp_pydantic,
swap_alert=StyleSwapAlert(
session_id=session_id,
detected=detected,
distance=round(distance, 4),
threshold=distance_threshold,
first_half=first_fp,
second_half=second_fp,
reason=reason,
),
)
full = fingerprint(events)
detected, distance = detect_mid_session_change(
events,
split_ratio=split_ratio,
distance_threshold=distance_threshold,
)
# Compute both halves for diagnostic context, even when we cannot
# actually compare them — this lets downstream consumers see *why*
# detection returned ``False``.
cut = int(len(events) * split_ratio)
first, second = events[:cut], events[cut:]
first_plans = sum(1 for e in first if e.event_type == EventType.PLANNER_OUTPUT)
second_plans = sum(1 for e in second if e.event_type == EventType.PLANNER_OUTPUT)
first_fp = self._pydantic_fingerprint(fingerprint(first), sample_size=first_plans)
second_fp = self._pydantic_fingerprint(fingerprint(second), sample_size=second_plans)
session_id = events[0].session_id if events else ""
reason: str | None = None
if not detected:
if len(events) < 6:
reason = "insufficient_events"
elif first_plans == 0 or second_plans == 0:
reason = "insufficient_planner_signal"
else:
reason = "below_threshold"
plans_count = sum(1 for e in events if e.event_type == EventType.PLANNER_OUTPUT)
fp_pydantic = self._pydantic_fingerprint(full, sample_size=plans_count)
return FingerprintReport(
session_id=session_id,
fingerprint=fp_pydantic,
swap_alert=StyleSwapAlert(
session_id=session_id,
detected=detected,
distance=round(distance, 4),
threshold=distance_threshold,
first_half=first_fp,
second_half=second_fp,
reason=reason,
),
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agentwatch/reasoning/auditor.py` around lines 150 - 186, The `reason` logic
in the fingerprint report is ambiguous because `detect_mid_session_change()`
returns `False, 0.0` both for missing planner output and for genuinely identical
halves. Update the `auditor.py` flow around `detect_mid_session_change`,
`FingerprintReport`, and `StyleSwapAlert` so that “insufficient_planner_signal”
is derived from a reliable signal such as half-level `sample_size`/planner
counts, and reserve “below_threshold” for real zero-distance or low-distance
comparisons. This keeps the diagnostic `reason` accurate when `distance == 0.0`
but both halves still have planner events.


@staticmethod
def _pydantic_fingerprint(
fp: StyleFingerprint,
*,
sample_size: int | None = None,
) -> ReasoningStyleFingerprint:
"""Bridge from the dataclass fingerprint to the schema model."""
instance = ReasoningStyleFingerprint.from_dataclass(fp)
if sample_size is not None:
instance = instance.model_copy(update={"sample_size": sample_size})
return instance

def _build_prompt(self, event: AgentEvent) -> str:
return (
"Judge the quality of this agent reasoning artifact on a 0-1 scale.\n"
Expand Down
153 changes: 153 additions & 0 deletions tests/test_reasoning_style_fingerprint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""Tests for RSN-008 reasoning style fingerprint integration with the auditor.

Covers:
- ReasoningStyleFingerprint and StyleSwapAlert schema models.
- ReasoningAuditor.fingerprint_session() end-to-end.
- Auto-derivation of StyleSwapAlert.reason for non-detection cases.
"""

from __future__ import annotations

from datetime import UTC, datetime

import pytest

from agentwatch.core.schema import (
AgentEvent,
EventType,
ReasoningStyleFingerprint,
StyleSwapAlert,
)
from agentwatch.reasoning.auditor import ReasoningAuditor
from agentwatch.reasoning.fingerprint import StyleFingerprint


def _plan(text: str, index: int) -> AgentEvent:
return AgentEvent(
session_id="S",
agent_id="A",
event_type=EventType.PLANNER_OUTPUT,
planner_output_preview=text,
step_number=index,
timestamp=datetime(2026, 1, 1, tzinfo=UTC),
)


def _tool(index: int) -> AgentEvent:
return AgentEvent(
session_id="S",
agent_id="A",
event_type=EventType.TOOL_CALL,
step_number=index,
timestamp=datetime(2026, 1, 1, tzinfo=UTC),
)


def test_reasoning_style_fingerprint_from_dataclass():
"""Adapt the dataclass form to the pydantic schema model."""
fp = StyleFingerprint(
mean_planner_tokens=12.5,
lex_diversity=0.8,
tools_per_plan=2.0,
punctuation_rate=0.05,
)
pyd = ReasoningStyleFingerprint.from_dataclass(fp)
assert pyd.mean_planner_tokens == 12.5
assert pyd.lex_diversity == 0.8
assert pyd.tools_per_plan == 2.0
assert pyd.punctuation_rate == 0.05
assert pyd.sample_size == 0


def test_style_swap_alert_serialises():
"""StyleSwapAlert round-trips through model_dump for the API."""
fp = ReasoningStyleFingerprint(
mean_planner_tokens=10.0,
lex_diversity=0.6,
tools_per_plan=1.5,
punctuation_rate=0.04,
)
alert = StyleSwapAlert(
session_id="S",
detected=True,
distance=1.2,
threshold=1.0,
first_half=fp,
second_half=fp,
)
out = alert.model_dump()
assert out["session_id"] == "S"
assert out["detected"] is True
assert out["distance"] == 1.2
assert out["first_half"]["mean_planner_tokens"] == 10.0


def test_fingerprint_session_no_swap_detected_consistent_plans():
plan = "Read the configuration file carefully and apply the migration step by step."
events = [_plan(plan, i) for i in range(8)]
auditor = ReasoningAuditor()
report = auditor.fingerprint_session(events)

assert report.session_id == "S"
assert report.fingerprint.sample_size == 8
assert report.swap_alert.detected is False
assert report.swap_alert.distance == 0.0
assert report.swap_alert.reason in {"insufficient_planner_signal", "below_threshold"}


def test_fingerprint_session_detects_style_swap():
"""When planner verbosity changes mid-session, detection should fire."""
short_plans = [_plan("ok", i) for i in range(4)]
verbose_plans = [
_plan(
"A carefully constructed plan that explores all the relevant "
"details and considerations before committing to a course of "
"action. Multiple sentences? Considerable detail!",
i + 4,
)
for i in range(4)
]
events = short_plans + verbose_plans
auditor = ReasoningAuditor()
report = auditor.fingerprint_session(events, distance_threshold=0.3)

assert report.swap_alert.detected is True
assert report.swap_alert.distance > 0.3
assert report.swap_alert.threshold == 0.3
assert report.swap_alert.reason is None # detected -> no reason blob


def test_fingerprint_session_short_circuit_records_reason():
events = [_plan("one", 0), _tool(1)]
auditor = ReasoningAuditor()
report = auditor.fingerprint_session(events)
assert report.swap_alert.detected is False
assert report.swap_alert.reason == "insufficient_events"


def test_fingerprint_report_to_dict_roundtrip():
plan = "A plan with multiple words and detail for testing serialization purposes."
events = [_plan(plan, i) for i in range(6)]
auditor = ReasoningAuditor()
report = auditor.fingerprint_session(events)
data = report.to_dict()
assert "fingerprint" in data
assert "swap_alert" in data
assert isinstance(data["fingerprint"]["sample_size"], int)


@pytest.mark.parametrize(
"events, expected_reason",
[
([], "insufficient_events"),
([_plan("x", 0)], "insufficient_events"),
([_plan("plan", i) for i in range(5)], None), # below threshold in even dist
],
)
def test_fingerprint_session_handles_edge_cases(events, expected_reason):
auditor = ReasoningAuditor()
report = auditor.fingerprint_session(events)
if expected_reason is None:
assert report.swap_alert.detected is False
else:
assert report.swap_alert.reason == expected_reason
Comment on lines +139 to +153

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Misleading comment / weak assertion for the 5-event parametrized case.

[_plan("plan", i) for i in range(5)] has only 5 events, which is < 6, so fingerprint_session will always short-circuit to reason = "insufficient_events" (line 166 in auditor.py) — never reaching a "below threshold" branch as the inline comment # below threshold in even dist implies. The test also doesn't assert reason for this row (only detected is False), so the mismatch goes unnoticed. Consider bumping this case to 6+ events and asserting reason == "below_threshold" to actually cover that path, or correcting the comment if the intent was just to exercise the "insufficient_events" boundary again.

🐛 Proposed fix
         ([], "insufficient_events"),
         ([_plan("x", 0)], "insufficient_events"),
-        ([_plan("plan", i) for i in range(5)], None),  # below threshold in even dist
+        ([_plan("plan", i) for i in range(6)], "below_threshold"),
     ],
 )
 def test_fingerprint_session_handles_edge_cases(events, expected_reason):
     auditor = ReasoningAuditor()
     report = auditor.fingerprint_session(events)
-    if expected_reason is None:
-        assert report.swap_alert.detected is False
-    else:
-        assert report.swap_alert.reason == expected_reason
+    assert report.swap_alert.detected is False
+    assert report.swap_alert.reason == expected_reason
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@pytest.mark.parametrize(
"events, expected_reason",
[
([], "insufficient_events"),
([_plan("x", 0)], "insufficient_events"),
([_plan("plan", i) for i in range(5)], None), # below threshold in even dist
],
)
def test_fingerprint_session_handles_edge_cases(events, expected_reason):
auditor = ReasoningAuditor()
report = auditor.fingerprint_session(events)
if expected_reason is None:
assert report.swap_alert.detected is False
else:
assert report.swap_alert.reason == expected_reason
`@pytest.mark.parametrize`(
"events, expected_reason",
[
([], "insufficient_events"),
([_plan("x", 0)], "insufficient_events"),
([_plan("plan", i) for i in range(6)], "below_threshold"),
],
)
def test_fingerprint_session_handles_edge_cases(events, expected_reason):
auditor = ReasoningAuditor()
report = auditor.fingerprint_session(events)
assert report.swap_alert.detected is False
assert report.swap_alert.reason == expected_reason
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_reasoning_style_fingerprint.py` around lines 139 - 153, The
5-event parametrized case in test_fingerprint_session_handles_edge_cases is
misleading because fingerprint_session short-circuits on insufficient_events
before any below-threshold logic runs. Update the case using _plan and
ReasoningAuditor so it uses 6+ events if you intend to cover the below_threshold
branch, and assert report.swap_alert.reason == "below_threshold" for that row;
otherwise, change the inline comment to match the insufficient_events boundary
behavior.

Loading