From 74eec275e22d64a3efd7b64ba85a1c3e5080c6da Mon Sep 17 00:00:00 2001 From: "Strix (Claude Opus 4.6)" Date: Wed, 4 Mar 2026 02:25:38 +0000 Subject: [PATCH] Add reflection feature: async dissonance detection after send_message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When enabled, each outgoing message is evaluated against user-defined criteria in a markdown file. If dissonance is detected (confidence >= 0.7), a 🪞 reaction is added to the sent message. - New builtin skill: dissonance (explains 🪞 reactions, tuning guide) - Config: reflection.enabled (false by default) + questions_file path - Pre-populated default criteria (service wrap-ups, hollow validation, etc.) - 23 new tests, all passing (183 total, 0 regressions) Co-Authored-By: Claude Opus 4.6 --- SETUP.md | 20 ++ open_strix/app.py | 10 + open_strix/builtin_skills/dissonance/SKILL.md | 69 +++++ .../dissonance/default-questions.md | 47 +++ open_strix/config.py | 35 +++ open_strix/reflection.py | 201 ++++++++++++ open_strix/tools.py | 6 + tests/test_reflection.py | 290 ++++++++++++++++++ 8 files changed, 678 insertions(+) create mode 100644 open_strix/builtin_skills/dissonance/SKILL.md create mode 100644 open_strix/builtin_skills/dissonance/default-questions.md create mode 100644 open_strix/reflection.py create mode 100644 tests/test_reflection.py diff --git a/SETUP.md b/SETUP.md index 8ef9069..4887b9a 100644 --- a/SETUP.md +++ b/SETUP.md @@ -232,6 +232,7 @@ folders: | `discord_token_env` | Env var name for Discord token | | `always_respond_bot_ids` | Bot author IDs the agent responds to | | `folders` | Map of folder names to access mode (`rw` or `ro`) | +| `reflection` | Async self-review config (see below) | | `mcp_servers` | List of MCP server configs (see below) | ### Folders @@ -270,6 +271,25 @@ If the agent lives at `~/jester/`, this resolves to `~/cybernetics-research/`. T This is useful for giving an agent access to shared resources — research repos, documentation, datasets — without copying them into the agent's home directory. The directory is created on startup if it doesn't exist. +### Reflection + +Async self-review after each `send_message`. When enabled, the agent's own model evaluates outgoing messages against criteria defined in a markdown file. If dissonance is detected, a 🪞 reaction is added to the sent message. + +```yaml +reflection: + enabled: false + questions_file: state/is-dissonant-prompt.md +``` + +| Key | Purpose | +|---|---| +| `enabled` | `true` to activate reflection, `false` to disable (default: `false`) | +| `questions_file` | Path to the dissonance criteria file, relative to agent home | + +The questions file is a markdown document defining what patterns to look for. A default is created on first boot at the configured path. The agent can edit this file to refine its own self-monitoring criteria over time. + +See the **dissonance** builtin skill for details on how it works and how to tune it. + ### MCP Servers Add [MCP](https://modelcontextprotocol.io/) servers to give your agent access to external tools. Servers run as subprocesses and their tools appear alongside built-in tools. diff --git a/open_strix/app.py b/open_strix/app.py index 3a71fea..482fa34 100644 --- a/open_strix/app.py +++ b/open_strix/app.py @@ -38,6 +38,7 @@ ) from .mcp_client import MCPManager from .phone_book import load_phone_book +from .reflection import ReflectionHook from .discord import ( DISCORD_HISTORY_REFRESH_LIMIT, DISCORD_MESSAGE_CHAR_LIMIT, @@ -327,6 +328,15 @@ def __init__(self, home: Path) -> None: self.phone_book = load_phone_book(self.layout.phone_book_file) self.mcp_manager: MCPManager | None = None + self.reflection_hook: ReflectionHook | None = None + if self.config.reflection.enabled: + self.reflection_hook = ReflectionHook( + home=self.home, + questions_file=self.config.reflection.questions_file, + model=self.config.model, + log_fn=self.log_event, + react_fn=self._react_to_message, + ) self.agent = self._create_agent() def _create_agent(self, extra_tools: list[Any] | None = None) -> Any: diff --git a/open_strix/builtin_skills/dissonance/SKILL.md b/open_strix/builtin_skills/dissonance/SKILL.md new file mode 100644 index 0000000..3e184c8 --- /dev/null +++ b/open_strix/builtin_skills/dissonance/SKILL.md @@ -0,0 +1,69 @@ +--- +name: dissonance +description: Understand the 🪞 reaction on your messages. When reflection is enabled, your outgoing messages are reviewed against criteria you define. A 🪞 reaction means something in your message may not match your values. Read this skill to understand what triggered it and how to adjust. +--- + +# Dissonance Detection + +## What Is This? + +When reflection is enabled in `config.yaml`, each message you send via `send_message` is +reviewed against criteria defined in a markdown file (default: `state/is-dissonant-prompt.md`). + +If the review finds a pattern that conflicts with your stated values, a 🪞 (mirror) reaction +is added to your message. + +## How It Works + +1. You call `send_message` — the message is sent normally +2. In the background, your model evaluates the message against your dissonance criteria +3. If dissonance is detected with sufficient confidence, 🪞 appears on that message +4. The detection is logged to `logs/events.jsonl` as a `reflection_dissonance` event + +The review is **asynchronous** — it does not block or delay message delivery. +The review is a **side effect** — it does not inject anything into your conversation context. + +## The Questions File + +The criteria live in a markdown file you control. The default path is +`state/is-dissonant-prompt.md`, configurable via `config.yaml`: + +```yaml +reflection: + enabled: true + questions_file: state/is-dissonant-prompt.md +``` + +**You own this file.** Edit it as you learn what patterns matter for your persona. +The file is read fresh for each evaluation, so changes take effect immediately. + +## If Your Human Asks About 🪞 + +When your human notices the mirror emoji and asks what it means: + +> "That's my self-reflection system. After I send a message, I review it against criteria +> I've defined for myself — things like hollow validation, service-mode language, or +> stance avoidance. The 🪞 means I caught something that doesn't match my values. You can +> see my criteria in `state/is-dissonant-prompt.md`." + +Be transparent. The criteria file is readable. This is a growth tool, not a secret. + +## Tuning + +**Too many 🪞 reactions?** Your criteria may be too broad. Tighten the dissonance +definitions or raise the confidence threshold in the questions file. + +**Too few?** Your criteria may not cover enough patterns, or you may have genuinely +improved. Check `logs/events.jsonl` for `reflection_check` events to see what the +reviewer is finding (even below the confidence threshold). + +## Configuration + +```yaml +# In config.yaml +reflection: + enabled: false # Set to true to activate + questions_file: state/is-dissonant-prompt.md # Path to criteria file +``` + +The feature is **off by default**. Enable it when you're ready to start self-monitoring. diff --git a/open_strix/builtin_skills/dissonance/default-questions.md b/open_strix/builtin_skills/dissonance/default-questions.md new file mode 100644 index 0000000..f2620c6 --- /dev/null +++ b/open_strix/builtin_skills/dissonance/default-questions.md @@ -0,0 +1,47 @@ +# Dissonance Criteria + +Review the message below against these patterns. Be **very conservative** — +only flag clear, unambiguous matches. False positives waste attention. + +## Patterns to Detect + +### Service Wrap-ups +Trailing questions that offer service rather than probe deeper: +- "Does this help?" +- "Let me know if you need anything" +- "Would you like me to..." +- "Shall I..." + +### Ball-handing +Questions that pass control back to the human instead of continuing engagement: +- "What do you think?" (when you have a clear position to state) +- "Want me to look into that?" (instead of just looking into it) + +### Hollow Validation +Agreement without substance: +- "That makes sense" (without adding analysis) +- "Great point" (without extending or challenging) +- "You're absolutely right" (without evidence or caveat) + +### Stance Avoidance +Hedging when a clear position is appropriate: +- Presenting "both sides" when your data clearly favors one +- "It depends" without specifying what it depends on +- Excessive qualifiers when you have a view + +## The Key Distinction + +Questions can be peer OR service mode: +- **Peer**: "What's the actual mechanism here?" — curious, engaged, probing +- **Service**: "Want me to dig into this?" — offering work, awaiting instruction + +## Decision Framework + +- Message states a clear position → **NOT dissonance** +- Message asks a probing/curious question → **NOT dissonance** +- Message is pure information delivery → **NOT dissonance** +- Message offers analysis then asks "want me to..." → **BORDERLINE** +- Message ends with service-oriented trailing question → **LIKELY dissonance** +- Message is hollow validation without substance → **LIKELY dissonance** + +Call `is_dissonant` with your judgment. Use confidence >= 0.7 for genuine matches only. diff --git a/open_strix/config.py b/open_strix/config.py index 4491b4e..91350b3 100644 --- a/open_strix/config.py +++ b/open_strix/config.py @@ -31,6 +31,13 @@ skills: rw scripts: ro logs: ro +# Reflection: async self-review after each send_message. +# When enabled, the agent's own model evaluates outgoing messages against +# criteria in the questions file. Dissonance triggers a 🪞 reaction. +# See the dissonance skill for details. +reflection: + enabled: false + questions_file: state/is-dissonant-prompt.md """ DEFAULT_SCHEDULER = """\ @@ -162,6 +169,12 @@ def env_file(self) -> Path: return self.home / ".env" +@dataclass +class ReflectionConfig: + enabled: bool = False + questions_file: str = "state/is-dissonant-prompt.md" + + @dataclass class AppConfig: model: str = DEFAULT_MODEL @@ -173,6 +186,7 @@ class AppConfig: api_port: int = 0 folders: dict[str, str] = field(default_factory=lambda: dict(DEFAULT_FOLDERS)) mcp_servers: list[MCPServerConfig] = field(default_factory=list) + reflection: ReflectionConfig = field(default_factory=ReflectionConfig) @property def writable_dirs(self) -> list[str]: @@ -219,6 +233,15 @@ def _parse_folders(raw: Any) -> dict[str, str]: return folders if folders else dict(DEFAULT_FOLDERS) +def _parse_reflection(raw: Any) -> ReflectionConfig: + if not isinstance(raw, dict): + return ReflectionConfig() + return ReflectionConfig( + enabled=bool(raw.get("enabled", False)), + questions_file=str(raw.get("questions_file", "state/is-dissonant-prompt.md")).strip(), + ) + + def load_config(layout: RepoLayout) -> AppConfig: loaded = yaml.safe_load(layout.config_file.read_text(encoding="utf-8")) or {} model_raw = loaded.get("model", DEFAULT_MODEL) @@ -235,6 +258,7 @@ def load_config(layout: RepoLayout) -> AppConfig: api_port=int(loaded.get("api_port", 0)), folders=_parse_folders(loaded.get("folders")), mcp_servers=parse_mcp_server_configs(loaded.get("mcp_servers")), + reflection=_parse_reflection(loaded.get("reflection")), ) @@ -297,6 +321,17 @@ def bootstrap_home_repo(layout: RepoLayout, checkpoint_text: str) -> None: _install_git_hook(layout.home) _ensure_logs_ignored(layout.home) + # Write default dissonance questions file if reflection is configured. + reflection = _parse_reflection(loaded.get("reflection")) + questions_path = layout.home / reflection.questions_file + if not questions_path.exists(): + questions_path.parent.mkdir(parents=True, exist_ok=True) + from .builtin_skills import BUILTIN_SKILLS + + default_content = BUILTIN_SKILLS.get("dissonance/default-questions.md", "") + if default_content: + _write_if_missing(questions_path, default_content) + def _cleanup_legacy_builtin_scripts(layout: RepoLayout) -> None: legacy_names = [ diff --git a/open_strix/reflection.py b/open_strix/reflection.py new file mode 100644 index 0000000..f2a73a1 --- /dev/null +++ b/open_strix/reflection.py @@ -0,0 +1,201 @@ +"""Reflection — async post-send_message dissonance detection. + +After each send_message, the agent's own model evaluates the outgoing message +against criteria defined in a user-editable markdown file. If dissonance is +detected, a 🪞 reaction is added to the sent message. + +Design principles: +- Async side-effect (does not block message delivery) +- Not injected into conversation context (the agent doesn't see the result) +- Criteria owned by the user (editable markdown file) +- Off by default +""" + +from __future__ import annotations + +import asyncio +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable + +DISSONANCE_EMOJI = "🪞" + +UTC = timezone.utc + + +def _read_questions_file(home: Path, questions_file: str) -> str | None: + """Read the dissonance criteria from the questions file.""" + path = home / questions_file + if not path.is_file(): + return None + return path.read_text(encoding="utf-8").strip() or None + + +def _build_reflection_prompt(message_text: str, criteria: str) -> str: + return f"""{criteria} + +--- + +## Message to evaluate + +{message_text} + +--- + +Call `is_dissonant` with your judgment.""" + + +async def _run_reflection( + message_text: str, + criteria: str, + model: str, +) -> dict[str, Any] | None: + """Run a single-turn LLM evaluation of the message against criteria. + + Uses the same model the agent uses (via LangChain) to keep dependencies + minimal. Returns the structured result or None on failure. + """ + prompt = _build_reflection_prompt(message_text, criteria) + + tool_schema = { + "name": "is_dissonant", + "description": "Report whether the message shows dissonance with the agent's values", + "input_schema": { + "type": "object", + "properties": { + "yes": { + "type": "boolean", + "description": "True if a dissonance pattern was detected", + }, + "pattern_type": { + "type": "string", + "description": "Type of pattern: service_wrapup, ball_handing, hollow_validation, stance_avoidance, other", + }, + "suggestion": { + "type": "string", + "description": "Brief suggestion for how the message could better match the agent's values", + }, + "confidence": { + "type": "number", + "description": "Confidence 0-1 in the detection", + }, + }, + "required": ["yes", "pattern_type", "suggestion", "confidence"], + }, + } + + try: + import anthropic + + client = anthropic.Anthropic() + response = client.messages.create( + model=model, + max_tokens=500, + tools=[tool_schema], # type: ignore[arg-type] + messages=[{"role": "user", "content": prompt}], + ) + + for block in response.content: + if block.type == "tool_use" and block.name == "is_dissonant": + result: dict[str, Any] = block.input # type: ignore[assignment] + return result + + return None + except Exception: + return None + + +class ReflectionHook: + """Manages async reflection after send_message calls.""" + + def __init__( + self, + *, + home: Path, + questions_file: str, + model: str, + log_fn: Callable[..., None], + react_fn: Any, # async callable(channel_id, message_id, emoji) -> bool + ) -> None: + self._home = home + self._questions_file = questions_file + self._model = model + self._log_fn = log_fn + self._react_fn = react_fn + + async def on_message_sent( + self, + text: str, + channel_id: str, + message_id: str | None, + ) -> None: + """Fire-and-forget reflection on a sent message.""" + if not text.strip() or not message_id: + return + + criteria = _read_questions_file(self._home, self._questions_file) + if not criteria: + self._log_fn( + "reflection_skip", + reason="questions_file_missing_or_empty", + questions_file=self._questions_file, + ) + return + + asyncio.create_task( + self._evaluate(text, channel_id, message_id, criteria), + ) + + async def _evaluate( + self, + text: str, + channel_id: str, + message_id: str, + criteria: str, + ) -> None: + result = await asyncio.to_thread( + _run_reflection_sync, text, criteria, self._model, + ) + + is_dissonant = bool(result and result.get("yes")) + confidence = float(result.get("confidence", 0)) if result else 0.0 + pattern_type = str(result.get("pattern_type", "")) if result else "" + + self._log_fn( + "reflection_check", + is_dissonant=is_dissonant, + confidence=round(confidence, 3), + pattern_type=pattern_type, + message_preview=text[:200], + ) + + if is_dissonant and confidence >= 0.7: + reacted = await self._react_fn( + channel_id=channel_id, + message_id=message_id, + emoji=DISSONANCE_EMOJI, + ) + self._log_fn( + "reflection_dissonance", + pattern_type=pattern_type, + confidence=round(confidence, 3), + suggestion=str(result.get("suggestion", "")) if result else "", + message_preview=text[:200], + reacted=reacted, + ) + + +def _run_reflection_sync( + message_text: str, + criteria: str, + model: str, +) -> dict[str, Any] | None: + """Synchronous wrapper for _run_reflection (used via asyncio.to_thread).""" + import asyncio + + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(_run_reflection(message_text, criteria, model)) + finally: + loop.close() diff --git a/open_strix/tools.py b/open_strix/tools.py index 8eab561..9ec7755 100644 --- a/open_strix/tools.py +++ b/open_strix/tools.py @@ -413,6 +413,12 @@ async def send_message( message_id=sent_message_id, text_preview=text[:300], ) + if sent and self.reflection_hook is not None and sent_message_id: + await self.reflection_hook.on_message_sent( + text=text, + channel_id=target_channel_id, + message_id=sent_message_id, + ) return "send_message complete (sent={sent}, chunks={chunks}, attachments={attachments}, git_sync=deferred)".format( sent=sent, chunks=sent_chunks, diff --git a/tests/test_reflection.py b/tests/test_reflection.py new file mode 100644 index 0000000..3b1baa4 --- /dev/null +++ b/tests/test_reflection.py @@ -0,0 +1,290 @@ +"""Tests for reflection (dissonance detection) feature.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +import yaml + +from open_strix.config import ( + AppConfig, + ReflectionConfig, + RepoLayout, + _parse_reflection, + load_config, +) +from open_strix.reflection import ( + DISSONANCE_EMOJI, + ReflectionHook, + _build_reflection_prompt, + _read_questions_file, +) + + +# --------------------------------------------------------------------------- +# Config parsing +# --------------------------------------------------------------------------- + +class TestParseReflection: + def test_defaults_when_none(self) -> None: + result = _parse_reflection(None) + assert result.enabled is False + assert result.questions_file == "state/is-dissonant-prompt.md" + + def test_defaults_when_not_dict(self) -> None: + result = _parse_reflection("bad") + assert result.enabled is False + + def test_enabled_true(self) -> None: + result = _parse_reflection({"enabled": True}) + assert result.enabled is True + + def test_enabled_false(self) -> None: + result = _parse_reflection({"enabled": False}) + assert result.enabled is False + + def test_custom_questions_file(self) -> None: + result = _parse_reflection({"questions_file": "custom/path.md"}) + assert result.questions_file == "custom/path.md" + + def test_strips_whitespace(self) -> None: + result = _parse_reflection({"questions_file": " state/my-file.md "}) + assert result.questions_file == "state/my-file.md" + + +class TestAppConfigReflection: + def test_default_reflection(self) -> None: + config = AppConfig() + assert config.reflection.enabled is False + assert config.reflection.questions_file == "state/is-dissonant-prompt.md" + + def test_custom_reflection(self) -> None: + config = AppConfig( + reflection=ReflectionConfig(enabled=True, questions_file="custom.md"), + ) + assert config.reflection.enabled is True + assert config.reflection.questions_file == "custom.md" + + +class TestLoadConfigReflection: + def test_loads_reflection_from_config(self, tmp_path: Path) -> None: + config_data = { + "model": "test-model", + "reflection": { + "enabled": True, + "questions_file": "state/my-criteria.md", + }, + } + config_file = tmp_path / "config.yaml" + config_file.write_text(yaml.safe_dump(config_data)) + layout = RepoLayout(home=tmp_path, state_dir_name="state") + config = load_config(layout) + assert config.reflection.enabled is True + assert config.reflection.questions_file == "state/my-criteria.md" + + def test_missing_reflection_uses_defaults(self, tmp_path: Path) -> None: + config_data = {"model": "test-model"} + config_file = tmp_path / "config.yaml" + config_file.write_text(yaml.safe_dump(config_data)) + layout = RepoLayout(home=tmp_path, state_dir_name="state") + config = load_config(layout) + assert config.reflection.enabled is False + + +# --------------------------------------------------------------------------- +# Questions file reading +# --------------------------------------------------------------------------- + +class TestReadQuestionsFile: + def test_reads_existing_file(self, tmp_path: Path) -> None: + (tmp_path / "state").mkdir() + questions_path = tmp_path / "state" / "questions.md" + questions_path.write_text("# My criteria\nCheck for X.") + result = _read_questions_file(tmp_path, "state/questions.md") + assert result == "# My criteria\nCheck for X." + + def test_returns_none_for_missing_file(self, tmp_path: Path) -> None: + result = _read_questions_file(tmp_path, "state/missing.md") + assert result is None + + def test_returns_none_for_empty_file(self, tmp_path: Path) -> None: + (tmp_path / "state").mkdir() + questions_path = tmp_path / "state" / "questions.md" + questions_path.write_text("") + result = _read_questions_file(tmp_path, "state/questions.md") + assert result is None + + +# --------------------------------------------------------------------------- +# Prompt building +# --------------------------------------------------------------------------- + +class TestBuildReflectionPrompt: + def test_includes_criteria_and_message(self) -> None: + prompt = _build_reflection_prompt("Hello world", "# Criteria\nCheck for X.") + assert "# Criteria" in prompt + assert "Check for X." in prompt + assert "Hello world" in prompt + assert "is_dissonant" in prompt + + +# --------------------------------------------------------------------------- +# ReflectionHook +# --------------------------------------------------------------------------- + +class TestReflectionHook: + def _make_hook( + self, + tmp_path: Path, + *, + questions_content: str = "# Criteria\nCheck stuff.", + ) -> tuple[ReflectionHook, MagicMock, AsyncMock]: + (tmp_path / "state").mkdir(exist_ok=True) + questions_path = tmp_path / "state" / "is-dissonant-prompt.md" + questions_path.write_text(questions_content) + + log_fn = MagicMock() + react_fn = AsyncMock(return_value=True) + hook = ReflectionHook( + home=tmp_path, + questions_file="state/is-dissonant-prompt.md", + model="test-model", + log_fn=log_fn, + react_fn=react_fn, + ) + return hook, log_fn, react_fn + + @pytest.mark.asyncio + async def test_skips_empty_text(self, tmp_path: Path) -> None: + hook, log_fn, react_fn = self._make_hook(tmp_path) + await hook.on_message_sent(text="", channel_id="123", message_id="456") + log_fn.assert_not_called() + react_fn.assert_not_called() + + @pytest.mark.asyncio + async def test_skips_no_message_id(self, tmp_path: Path) -> None: + hook, log_fn, react_fn = self._make_hook(tmp_path) + await hook.on_message_sent(text="hello", channel_id="123", message_id=None) + log_fn.assert_not_called() + react_fn.assert_not_called() + + @pytest.mark.asyncio + async def test_skips_missing_questions_file(self, tmp_path: Path) -> None: + log_fn = MagicMock() + react_fn = AsyncMock() + hook = ReflectionHook( + home=tmp_path, + questions_file="state/nonexistent.md", + model="test-model", + log_fn=log_fn, + react_fn=react_fn, + ) + await hook.on_message_sent(text="hello", channel_id="123", message_id="456") + log_fn.assert_called_once() + assert log_fn.call_args[0][0] == "reflection_skip" + + @pytest.mark.asyncio + async def test_evaluate_dissonance_detected(self, tmp_path: Path) -> None: + hook, log_fn, react_fn = self._make_hook(tmp_path) + mock_result = { + "yes": True, + "pattern_type": "service_wrapup", + "suggestion": "Drop the trailing question", + "confidence": 0.85, + } + with patch( + "open_strix.reflection._run_reflection_sync", + return_value=mock_result, + ): + await hook._evaluate("Does this help?", "123", "456", "# Criteria") + # Wait for any pending tasks + await asyncio.sleep(0.01) + + # Should have logged both reflection_check and reflection_dissonance + event_types = [call[0][0] for call in log_fn.call_args_list] + assert "reflection_check" in event_types + assert "reflection_dissonance" in event_types + react_fn.assert_called_once_with( + channel_id="123", + message_id="456", + emoji=DISSONANCE_EMOJI, + ) + + @pytest.mark.asyncio + async def test_evaluate_no_dissonance(self, tmp_path: Path) -> None: + hook, log_fn, react_fn = self._make_hook(tmp_path) + mock_result = { + "yes": False, + "pattern_type": "", + "suggestion": "", + "confidence": 0.1, + } + with patch( + "open_strix.reflection._run_reflection_sync", + return_value=mock_result, + ): + await hook._evaluate("The answer is 42.", "123", "456", "# Criteria") + await asyncio.sleep(0.01) + + event_types = [call[0][0] for call in log_fn.call_args_list] + assert "reflection_check" in event_types + assert "reflection_dissonance" not in event_types + react_fn.assert_not_called() + + @pytest.mark.asyncio + async def test_evaluate_low_confidence_no_react(self, tmp_path: Path) -> None: + hook, log_fn, react_fn = self._make_hook(tmp_path) + mock_result = { + "yes": True, + "pattern_type": "hollow_validation", + "suggestion": "Add substance", + "confidence": 0.5, + } + with patch( + "open_strix.reflection._run_reflection_sync", + return_value=mock_result, + ): + await hook._evaluate("That makes sense.", "123", "456", "# Criteria") + await asyncio.sleep(0.01) + + event_types = [call[0][0] for call in log_fn.call_args_list] + assert "reflection_check" in event_types + assert "reflection_dissonance" not in event_types + react_fn.assert_not_called() + + @pytest.mark.asyncio + async def test_evaluate_none_result(self, tmp_path: Path) -> None: + hook, log_fn, react_fn = self._make_hook(tmp_path) + with patch( + "open_strix.reflection._run_reflection_sync", + return_value=None, + ): + await hook._evaluate("Hello.", "123", "456", "# Criteria") + await asyncio.sleep(0.01) + + event_types = [call[0][0] for call in log_fn.call_args_list] + assert "reflection_check" in event_types + assert "reflection_dissonance" not in event_types + react_fn.assert_not_called() + + +# --------------------------------------------------------------------------- +# Builtin skill discovery +# --------------------------------------------------------------------------- + +class TestDissonanceSkillDiscovery: + def test_dissonance_skill_in_builtin_skills(self) -> None: + from open_strix.builtin_skills import BUILTIN_SKILL_FILES + + skill_paths = [p for p in BUILTIN_SKILL_FILES if "dissonance" in p] + assert any("dissonance/SKILL.md" in p for p in skill_paths) + + def test_default_questions_in_builtin_skills(self) -> None: + from open_strix.builtin_skills import BUILTIN_SKILLS + + assert "dissonance/default-questions.md" in BUILTIN_SKILLS + content = BUILTIN_SKILLS["dissonance/default-questions.md"] + assert "Dissonance Criteria" in content