From 827ca411a039dcd54d45ee984deca8cfa27d5492 Mon Sep 17 00:00:00 2001 From: Violin Date: Wed, 22 Jul 2026 11:29:35 +0100 Subject: [PATCH] Record research receipts for semantic unlocks --- plugins/violin_guard/__init__.py | 6 +++++ plugins/violin_guard/state.py | 22 ++++++++++++++++++- tests/guard/state/test_semantic_anti_stuck.py | 3 ++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/plugins/violin_guard/__init__.py b/plugins/violin_guard/__init__.py index 04025b4..58f9597 100644 --- a/plugins/violin_guard/__init__.py +++ b/plugins/violin_guard/__init__.py @@ -167,6 +167,12 @@ def _post_tool_call_hook(tool_name=None, args=None, result=None, duration_ms=0, if tool_name == "execute_code" and isinstance(args, dict): with contextlib.suppress(Exception): code_execution_audit.record_completion(args.get("code"), result, duration_ms) + if tool_name in {"web_search", "web_extract"}: + session_id = str(kwargs.get("session_id") or "") + eng_dir = _SESSION_ENGAGEMENTS.get(session_id) + if eng_dir: + with contextlib.suppress(Exception): + state.record_research_attempt(eng_dir, tool_name, not bool(result is None)) if tool_name not in {"violin_record_ptt", "violin_review_batch"}: return args = args if isinstance(args, dict) else {} diff --git a/plugins/violin_guard/state.py b/plugins/violin_guard/state.py index ce5bf51..22f887c 100644 --- a/plugins/violin_guard/state.py +++ b/plugins/violin_guard/state.py @@ -332,7 +332,8 @@ def record_semantic_review( next_technique.strip().lower() and next_technique.strip().lower() != technique.strip().lower() ) - if lock and research_attempted and pivoted: + research_after_lock = bool((data.get("research_attempts") or []) and lock) + if lock and research_after_lock and pivoted: data.pop("lock", None) elif count >= 5: data["lock"] = { @@ -345,6 +346,25 @@ def record_semantic_review( return mutate_json(path, record) +def record_research_attempt(eng_dir: str | Path, tool_name: str, success: bool) -> None: + """Record an actual web research-tool attempt for semantic-lock recovery.""" + + path = _state_dir(eng_dir) / _SEMANTIC_FILE + + def record(data: dict[str, Any]) -> None: + attempts = data.setdefault("research_attempts", []) + attempts.append( + { + "tool": tool_name, + "success": success, + "timestamp": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + } + ) + data["research_attempts"] = attempts[-20:] + + mutate_json(path, record) + + def semantic_lock(eng_dir: str | Path) -> dict[str, Any] | None: return read_json(_state_dir(eng_dir) / _SEMANTIC_FILE).get("lock") diff --git a/tests/guard/state/test_semantic_anti_stuck.py b/tests/guard/state/test_semantic_anti_stuck.py index de0d330..f500e1a 100644 --- a/tests/guard/state/test_semantic_anti_stuck.py +++ b/tests/guard/state/test_semantic_anti_stuck.py @@ -32,7 +32,8 @@ def test_semantic_reviews_warn_then_hard_lock_and_require_research_pivot(tmp_pat assert _review(tmp_path, research_attempted=True)["locked"] assert _review(tmp_path, next_technique="parameter-discovery")["locked"] - unlocked = _review(tmp_path, research_attempted=True, next_technique="parameter-discovery") + state.record_research_attempt(tmp_path, "web_search", True) + unlocked = _review(tmp_path, next_technique="parameter-discovery") assert not unlocked["locked"] assert state.semantic_lock(tmp_path) is None