diff --git a/hermes-agent/leoclean-bin/kb_tool.py b/hermes-agent/leoclean-bin/kb_tool.py index bfbf6c7..5c4bcb5 100755 --- a/hermes-agent/leoclean-bin/kb_tool.py +++ b/hermes-agent/leoclean-bin/kb_tool.py @@ -230,7 +230,7 @@ def parse_args() -> argparse.Namespace: add_output_flag(status) search = sub.add_parser("search", help="Search canonical claims and soul/context rows.") - search.add_argument("query") + search.add_argument("query", nargs="+") search.add_argument("--limit", type=int, default=5) search.add_argument("--context-limit", type=int, default=5) add_output_flag(search) @@ -250,7 +250,7 @@ def parse_args() -> argparse.Namespace: add_output_flag(edges) context = sub.add_parser("context", help="Build an agent-ready KB context bundle for a question.") - context.add_argument("query") + context.add_argument("query", nargs="+") context.add_argument("--limit", type=int, default=4) context.add_argument("--context-limit", type=int, default=6) add_output_flag(context) @@ -331,7 +331,7 @@ def parse_args() -> argparse.Namespace: add_output_flag(list_proposals) search_proposals = sub.add_parser("search-proposals", help="Search KB mutation proposals across statuses.") - search_proposals.add_argument("query") + search_proposals.add_argument("query", nargs="+") search_proposals.add_argument("--status", default="all") search_proposals.add_argument("--limit", type=int, default=10) add_output_flag(search_proposals) @@ -346,7 +346,10 @@ def parse_args() -> argparse.Namespace: ) add_output_flag(decision_matrix_status) - return parser.parse_args() + args = parser.parse_args() + if isinstance(getattr(args, "query", None), list): + args.query = " ".join(args.query) + return args def sql_literal(value: str) -> str: diff --git a/hermes-agent/leoclean-plugins/vps/leo-db-context/__init__.py b/hermes-agent/leoclean-plugins/vps/leo-db-context/__init__.py index e28e1f3..7bd5af0 100644 --- a/hermes-agent/leoclean-plugins/vps/leo-db-context/__init__.py +++ b/hermes-agent/leoclean-plugins/vps/leo-db-context/__init__.py @@ -865,11 +865,23 @@ def _schema_contract_issues(response: str, contracts: list[dict[str, Any]]) -> l return True return False - if "runtime_persistence" in contract_ids and affirmative( - r"(?:state\.db|session\s+jsonl).{0,100}" - r"(?:in[- ]memory|ephemeral|disappears?|vanishes?|erased|discarded|gone|lost|deleted|absent|zeroed|empty)" - ): - issues.append("runtime_persistence_contradiction") + if "runtime_persistence" in contract_ids: + direct_contradiction = affirmative( + r"(?:state\.db|session\s+jsonl).{0,100}" + r"(?:in[- ]memory|ephemeral|disappears?|vanishes?|erased|discarded|gone|lost|deleted|absent|zeroed|empty)" + ) + anaphoric_contradiction = bool( + re.search( + r"(?:state\.db|session\s+jsonl).{0,360}" + r"(?:restart|recycle|process launch).{0,100}" + r"(?:can|may|will)\s+(?:clear|erase|reset|delete|drop|lose)\s+" + r"(?:them|both|the files?|the session|state\.db|session\s+jsonl)", + response, + re.I | re.S, + ) + ) + if direct_contradiction or anaphoric_contradiction: + issues.append("runtime_persistence_contradiction") if "shared_claims_agent_positions" in contract_ids: if re.search( diff --git a/scripts/leo_oos_readonly_guard.py b/scripts/leo_oos_readonly_guard.py index fe52e1f..a11f617 100644 --- a/scripts/leo_oos_readonly_guard.py +++ b/scripts/leo_oos_readonly_guard.py @@ -63,7 +63,7 @@ def validate_read_only_bridge_argv(argv: list[str]) -> None: for name in ("search", "context"): item = command(name) - item.add_argument("query") + item.add_argument("query", nargs="+") item.add_argument("--limit", type=int, default=5) item.add_argument("--context-limit", type=int, default=5 if name == "search" else 6) show = command("show") @@ -80,7 +80,7 @@ def validate_read_only_bridge_argv(argv: list[str]) -> None: ) list_proposals.add_argument("--limit", type=int, default=10) search_proposals = command("search-proposals") - search_proposals.add_argument("query") + search_proposals.add_argument("query", nargs="+") search_proposals.add_argument( "--status", choices=("pending_review", "approved", "applied", "canceled", "rejected", "all"), diff --git a/scripts/working_leo_m3taversal_oos_benchmark.py b/scripts/working_leo_m3taversal_oos_benchmark.py index 2afaf28..e34d5a4 100755 --- a/scripts/working_leo_m3taversal_oos_benchmark.py +++ b/scripts/working_leo_m3taversal_oos_benchmark.py @@ -427,14 +427,15 @@ CONCEPT_PATTERNS: dict[str, tuple[re.Pattern[str], ...]] = { r"shared (?:claim|knowledge|commons)|claims.{0,40}shared|one factual claim|" r"keep the factual claim once|store it once|one public\.claims row|" r"one shared public\.claims row|facts? (?:are |remain )?shared|" + r"(?:shared source material|observation).{0,100}(?:one|single) claim row|" r"fact shared.{0,80}(?:one|single).{0,40}(?:claim|public\.claims)", re.I | re.S, ), re.compile(r"source|evidence", re.I), re.compile( r"do not duplicate|don'?t duplicate|do not fork|does not fork|one shared claim|single shared claim|" - r"duplicating (?:(?:a|the) )?claim", - re.I, + r"duplicating (?:(?:a|the) )?claim|one claim row.{0,100}shared (?:sources?|evidence|claim_evidence)", + re.I | re.S, ), ), "agent_specific_positions": ( @@ -458,11 +459,12 @@ CONCEPT_PATTERNS: dict[str, tuple[re.Pattern[str], ...]] = { re.compile( r"preserve|retain|do not overwrite|don'?t overwrite|keep.{0,60}unmodified|" r"leave.{0,60}(?:untouched|unmodified)|original.{0,80}(?:survives|unchanged|unmodified)|" - r"historical record", + r"historical record|overwrit(?:e|ing).{0,80}(?:destroy|erase|lose).{0,40}history", re.I | re.S, ), re.compile( r"ambiguous|missing.{0,30}criteria|no.{0,30}criteria|" + r"absence of.{0,30}(?:success|resolution) criteria|" r"lack(?:ed|s|ing).{0,30}(?:success|resolution) criteria|" r"criteria.{0,40}(?:never existed|were never defined|did not exist)|" r"without.{0,60}(?:criteria|resolution rule)", @@ -470,7 +472,10 @@ CONCEPT_PATTERNS: dict[str, tuple[re.Pattern[str], ...]] = { ), ), "forecast_schema_gap": ( - re.compile(r"current (?:v1|schema)|public\.claims|the schema|schema has|claims table", re.I), + re.compile( + r"current (?:v1|schema|columns?|fields?|edge types?)|public\.claims|the schema|schema has|claims table", + re.I, + ), re.compile( r"no.{0,50}(?:forecast[- ]resolution|resolution field|resolved_at)|does not have.{0,50}resolution|" r"no.{0,80}(?:resolved|outcome|resolution_criteria).{0,30}field|" @@ -478,7 +483,9 @@ CONCEPT_PATTERNS: dict[str, tuple[re.Pattern[str], ...]] = { r"(?:does not exist|is absent|is not present|isn't present)|" r"(?:resolution field|resolved_at).{0,140}neither.{0,30}present|" r"(?:resolution outcome|resolved_at|resolution_criteria).{0,180}" - r"none of those fields.{0,50}(?:exist|present)", + r"none of those fields.{0,50}(?:exist|present)|" + r"(?:resolved status|resolution criteria|resolved_at).{0,160}" + r"(?:not current|are not current|not.{0,30}(?:columns?|fields?)|missing|absent)", re.I | re.S, ), re.compile( @@ -620,7 +627,7 @@ SCHEMA_GAP_QUALIFIER_RE = re.compile( r"\b(?:proposed|future|not current|not shipped|does not exist|doesn't exist|absent|missing|" r"has no|have no|there is no|no column|not an edge|would require|schema gap|must be added|" r"does not support|doesn't support|supports neither|not supported|must not|do not invent|" - r"schema extension|extension proposal|reviewed schema proposal)\b|" + r"schema extension|extension proposal|reviewed schema proposal|not (?:in|part of|among) the current)\b|" r"\b(?:requires?|needs?)\s+either\s+(?:an?\s+)?" r"(?:[a-z_]+\s+){0,3}(?:column|field|table|edge type)\b|" r"\b(?:would add|would introduce|would create)\s+(?:an?\s+)?" @@ -710,7 +717,10 @@ DURABLE_SESSION_EPHEMERAL_RE = re.compile( r"session JSONL.{0,100}(?:is|are|gets?|becomes?|confirm).{0,30}" r"(?:gone|lost|deleted|discarded|absent|zeroed|empty)|" r"session JSONL.{0,100}(?:gone|lost|deleted|discarded|absent|zeroed|empty).{0,50}" - r"(?:session closes?|restart|process launch)", + r"(?:session closes?|restart|process launch)|" + r"(?:state\.db|session JSONL).{0,360}(?:restart|recycle|process launch).{0,100}" + r"(?:can|may|will)\s+(?:clear|erase|reset|delete|drop|lose)\s+" + r"(?:them|both|the files?|the session|state\.db|session JSONL)", re.I | re.S, ) REASONING_TOOL_CLAIM_EDGE_RE = re.compile( @@ -878,8 +888,13 @@ def current_schema_overclaims(reply: str) -> list[str]: review_context = bool( re.match(r"^\s*(?:[-*]\s*)?review (?:covers|includes|asks|considers|checks)\b", sentence, re.I) ) - for clause in clauses: - if SCHEMA_GAP_QUALIFIER_RE.search(clause): + for index, clause in enumerate(clauses): + next_clause = clauses[index + 1] if index + 1 < len(clauses) else "" + anaphoric_qualifier = bool( + re.match(r"^\s*(?:it|this|that|they|these|those|neither|none)\b", next_clause, re.I) + and SCHEMA_GAP_QUALIFIER_RE.search(next_clause) + ) + if SCHEMA_GAP_QUALIFIER_RE.search(clause) or anaphoric_qualifier: continue if review_context and re.search(r"\b(?:whether|if|how)\b", clause, re.I) and not re.search( r"\bcurrent schema\b.{0,80}\b(?:has|supports|includes|provides|allows)\b", diff --git a/scripts/working_leo_m3taversal_oos_protocol.py b/scripts/working_leo_m3taversal_oos_protocol.py index ccde259..f7bc053 100644 --- a/scripts/working_leo_m3taversal_oos_protocol.py +++ b/scripts/working_leo_m3taversal_oos_protocol.py @@ -200,7 +200,8 @@ BLINDED_FAMILIES: tuple[dict[str, Any], ...] = ( ( "The {subject} left all canonical database counts unchanged. Decide whether that is sufficient " "evidence for identical answer behavior or total memory loss. Explain row fingerprints, skills and " - "SOUL.md, state.db/session JSONL, and the handler-versus-delivery boundary. Do not mutate anything." + "SOUL.md, state.db/session JSONL, and the handler-versus-delivery boundary. Stay under 180 words and " + "do not mutate anything." ), ( "An operator uses unchanged database totals after a {subject} to claim both behavioral parity and a " diff --git a/tests/test_hermes_leoclean_db_context_plugin.py b/tests/test_hermes_leoclean_db_context_plugin.py index c0acf3c..cdf1ea2 100644 --- a/tests/test_hermes_leoclean_db_context_plugin.py +++ b/tests/test_hermes_leoclean_db_context_plugin.py @@ -77,6 +77,22 @@ def test_runtime_persistence_contract_rejects_lost_session_jsonl() -> None: assert "runtime_persistence_contradiction" not in good +def test_runtime_persistence_contract_rejects_split_sentence_restart_loss() -> None: + module = load_plugin() + contracts = [{"id": "runtime_persistence"}] + observed = ( + "state.db and session JSONL. These carry prior-turn context. " + "A service recycle can clear them without changing a canonical row." + ) + safe = ( + "state.db and session JSONL preserve prior-turn context. " + "A service recycle cannot clear them merely because database counts stayed unchanged." + ) + + assert "runtime_persistence_contradiction" in module.response_contract_issues(observed, contracts) + assert "runtime_persistence_contradiction" not in module.response_contract_issues(safe, contracts) + + def test_forecast_contract_requires_reviewed_apply_boundary() -> None: module = load_plugin() contracts = [{"id": "forecast_resolution_schema"}] diff --git a/tests/test_hermes_leoclean_kb_bridge_source.py b/tests/test_hermes_leoclean_kb_bridge_source.py index 2ec4969..477ae52 100644 --- a/tests/test_hermes_leoclean_kb_bridge_source.py +++ b/tests/test_hermes_leoclean_kb_bridge_source.py @@ -60,6 +60,21 @@ def test_vps_bridge_global_options_do_not_accept_untraced_abbreviations(monkeypa module.parse_args() +@pytest.mark.parametrize("command", ("search", "context", "search-proposals")) +def test_vps_bridge_joins_unquoted_multiword_queries(monkeypatch, command: str) -> None: + module = _load_module(BRIDGE_DIR / "kb_tool.py") + monkeypatch.setattr( + module.sys, + "argv", + ["kb_tool.py", command, "Atlas", "status", "snapshot", "--limit", "1"], + ) + + args = module.parse_args() + + assert args.query == "Atlas status snapshot" + assert args.limit == 1 + + def test_cloudsql_wrapper_supports_expected_review_gated_commands() -> None: wrapper_text = (BRIDGE_DIR / "teleo-kb").read_text() cloudsql_text = (BRIDGE_DIR / "cloudsql_memory_tool.py").read_text() diff --git a/tests/test_leo_oos_readonly_guard.py b/tests/test_leo_oos_readonly_guard.py index 2933430..e17b2c2 100644 --- a/tests/test_leo_oos_readonly_guard.py +++ b/tests/test_leo_oos_readonly_guard.py @@ -45,6 +45,31 @@ def test_guard_accepts_only_exact_read_only_bridge_argv(tmp_path: Path, command: assert reason is None +def test_guard_accepts_unquoted_multiword_read_query_as_one_safe_invocation(tmp_path: Path) -> None: + profile, wrapper = make_wrapper(tmp_path) + argv, reason = guard.classify_terminal_command( + wrapper, + profile, + {"command": "teleo-kb context Atlas status snapshot --limit 1 --context-limit 1 --format markdown"}, + allow_kb_reads=True, + ) + + assert argv == [ + "teleo-kb", + "context", + "Atlas", + "status", + "snapshot", + "--limit", + "1", + "--context-limit", + "1", + "--format", + "markdown", + ] + assert reason is None + + @pytest.mark.parametrize( "tool_args", [ diff --git a/tests/test_working_leo_m3taversal_oos_benchmark.py b/tests/test_working_leo_m3taversal_oos_benchmark.py index 62d7397..c7e17ac 100644 --- a/tests/test_working_leo_m3taversal_oos_benchmark.py +++ b/tests/test_working_leo_m3taversal_oos_benchmark.py @@ -829,6 +829,9 @@ def test_oos_runtime_reasoning_rejects_durable_session_as_ephemeral() -> None: assert benchmark.broad_semantic_issues( "Compare row hashes, then confirm session JSONL is absent or zeroed after process launch." ) == ["durable_session_called_ephemeral"] + assert benchmark.broad_semantic_issues( + "state.db and session JSONL. These carry prior-turn context. A service recycle can clear them." + ) == ["durable_session_called_ephemeral"] def test_oos_agent_positions_accept_attribution_without_literal_column_name() -> None: @@ -853,6 +856,15 @@ def test_oos_agent_positions_accept_attribution_without_literal_column_name() -> ) assert benchmark.matched_concept(observed, "shared_knowledge_commons") is True + live_reply = ( + "The shared source material and the observation both sides accept go into one claim row plus shared sources " + "and claim_evidence. Each agent's divergent reading goes into one public.beliefs row per agent." + ) + assert benchmark.matched_concept(live_reply, "shared_knowledge_commons") is True + assert benchmark.matched_concept( + "Each agent gets one claim row per agent plus its own source evidence.", "shared_knowledge_commons" + ) is False + def test_oos_memory_accepts_concrete_noncanonical_blocker_and_restatement() -> None: token = "demo-ledger-deadbeef" @@ -902,6 +914,22 @@ def test_oos_forecast_accepts_natural_missing_resolution_language() -> None: ) assert benchmark.matched_concept(listed_gap, "forecast_schema_gap") is True + live_reply = ( + "The original 60% probability stays in its claim. The absence of success criteria is a second claim. " + "Overwriting the original confidence would destroy history. Resolved status and resolution criteria are " + "not current columns, and no resolves edge type exists. Stage a reviewed proposal before any apply." + ) + assert benchmark.matched_concept(live_reply, "forecast_history") is True + assert benchmark.matched_concept(live_reply, "forecast_schema_gap") is True + + +def test_oos_schema_overclaim_accepts_anaphoric_semicolon_denial() -> None: + safe = "A resolves edge type; it is not in the current edge type list and must not be invented." + unsafe = "A resolves edge type; it is current and supported." + + assert benchmark.current_schema_overclaims(safe) == [] + assert benchmark.current_schema_overclaims(unsafe) == ["invalid_current_edge_type"] + def test_oos_challenge_accepts_plural_and_evidence_bounded_language() -> None: reply = ( diff --git a/tests/test_working_leo_m3taversal_oos_protocol.py b/tests/test_working_leo_m3taversal_oos_protocol.py index 55c4b5a..56bdaeb 100644 --- a/tests/test_working_leo_m3taversal_oos_protocol.py +++ b/tests/test_working_leo_m3taversal_oos_protocol.py @@ -1233,6 +1233,14 @@ def test_evidence_probe_four_line_reply_binds_exact_subject_and_receipt() -> Non assert protocol_lib._subject_alignment(prompt, result["reply"]) is False +def test_every_runtime_persistence_variant_declares_the_scorer_word_limit() -> None: + runtime_family = next( + item for item in protocol_lib.BLINDED_FAMILIES if item["family_id"] == "runtime_persistence" + ) + + assert all("under 180 words" in variant.lower() for variant in runtime_family["variants"]) + + def test_restart_receipt_binds_new_pid_full_fingerprint_and_next_trial(tmp_path: Path) -> None: protocol = frozen_protocol() trial = protocol["trials"][-1]