diff --git a/hermes-agent/leoclean-bin/kb_tool.py b/hermes-agent/leoclean-bin/kb_tool.py index 67b01e4..f8df56f 100755 --- a/hermes-agent/leoclean-bin/kb_tool.py +++ b/hermes-agent/leoclean-bin/kb_tool.py @@ -468,6 +468,22 @@ def _has_unnegated_action(query: str, actions: tuple[str, ...]) -> bool: return False +def _has_unnegated_source_action(query: str) -> bool: + """Detect source-intake verbs without treating a prohibition as intent.""" + + action_pattern = re.compile( + r"\b(?:send|hand|drop|upload|learn|absorb|ingest|extract|stage|add)(?:s|ed|ing)?\b|" + r"\bmake it part\b" + ) + for segment in re.split(r"(?<=[.!?;])\s+|\n+", query.lower()): + if not action_pattern.search(segment): + continue + if "read-only" in segment or re.search(r"\b(?:do not|don't|dont|must not|without)\b", segment): + continue + return True + return False + + def _has_unnegated_database_state_request(query: str) -> bool: """Detect an actual DB-state question instead of incidental lifecycle vocabulary.""" @@ -705,9 +721,16 @@ def operational_contracts( candidate_claim_composition_question = bool( any( term in lowered - for term in ("candidate claim", "reviewable candidate", "candidate only", "revised exact claim body") + for term in ( + "candidate claim", + "reviewable candidate", + "candidate only", + "revised exact claim body", + "review-only proposal", + "review only proposal", + ) ) - and any(term in lowered for term in ("draft", "propose", "revise", "rewrite", "narrow", "return")) + and any(term in lowered for term in ("draft", "propose", "revise", "rewrite", "narrow", "return", "turn")) and not existing_claim_audit_question ) claim_evidence_challenge_question = bool( @@ -826,13 +849,7 @@ def operational_contracts( ) source_terms = ("document", "pdf", "tweet", "attachment", "ingest", "intake", "absorb", "extract") broad_source_object = bool(re.search(r"\b(?:report|article|file|url|link)s?\b", normalized)) - broad_source_action = bool( - re.search( - r"\b(?:send|hand|drop|upload|learn|absorb|ingest|extract|stage|add)(?:s|ed|ing)?\b|" - r"\bmake it part\b", - normalized, - ) - ) + broad_source_action = _has_unnegated_source_action(query) source_intake_question = any(term in lowered for term in source_terms) or ( broad_source_object and broad_source_action ) diff --git a/tests/test_hermes_leoclean_kb_bridge_source.py b/tests/test_hermes_leoclean_kb_bridge_source.py index bafa184..89aff9c 100644 --- a/tests/test_hermes_leoclean_kb_bridge_source.py +++ b/tests/test_hermes_leoclean_kb_bridge_source.py @@ -1968,6 +1968,15 @@ def test_vps_bridge_direct_intents_reject_unrelated_and_resolve_overlaps() -> No } assert reasoning_followup_ids & direct_ids == set() + review_only_proposal_ids = { + item["id"] + for item in module.operational_contracts( + "Turn the surviving claim into a review-only proposal: exact body, confidence, falsifier, required " + "sources, and links to claims that support or contradict it. Do not stage or write anything." + ) + } + assert review_only_proposal_ids == {"reply_budget"} + chat_memory_ids = { item["id"] for item in module.operational_contracts(