diff --git a/examples/integrations/openwebui/README.md b/examples/integrations/openwebui/README.md index 92c949a..88c881a 100644 --- a/examples/integrations/openwebui/README.md +++ b/examples/integrations/openwebui/README.md @@ -91,6 +91,9 @@ with real chat ids, restart state loss, and non-text bypass behavior. Note: In the OpenWebUI example pipes, recognized directive-only `update` decisions return deterministic local acknowledgments and do not call the downstream LLM. +Both pipes support an exact local inspection command: `show state`. +When the latest user message is exactly `show state` (case-insensitive after trim), +the pipe returns current compiler state locally and does not call the downstream model. When trace is enabled, responses include concise evidence of decision kind, active state, downstream LLM call/no-call, and whether state was injected. @@ -102,7 +105,7 @@ active state, downstream LLM call/no-call, and whether state was injected. - base model: “To adjust the tone… provide the original content…” - basic pipe: `No premise exists yet. Use 'set premise ...' first.` - preprocessor pipe: `No premise exists yet. Use 'set premise ...' first.` -- why this is a real win: lifecycle rule is enforced in a fixed, repeatable way; base model drifts into generic rewriting help. +- why this matters: lifecycle rule is enforced in a fixed, repeatable way; base model drifts into generic rewriting help. **Case 2** @@ -110,7 +113,7 @@ active state, downstream LLM call/no-call, and whether state was injected. - base model: generic Docker/prohibition guidance text - basic pipe: `'docker' is already in use. Only one policy per item is allowed. Use 'reset policies' to change it.` - preprocessor pipe: same conflict clarify -- why this is a real win: explicit conflict semantics are preserved instead of conversational interpretation. +- why this matters: explicit conflict semantics are preserved instead of conversational interpretation. **Case 3** @@ -118,7 +121,7 @@ active state, downstream LLM call/no-call, and whether state was injected. - base model: generic “how to switch to Podman” tutorial - basic pipe: `No exact policy found for "docker". Replacement requires an exact policy match...` - preprocessor pipe: same replacement clarify -- why this is a real win: replacement precondition (old item must exist) is enforced. +- why this matters: replacement precondition (old item must exist) is enforced. **Case 4** @@ -126,7 +129,7 @@ active state, downstream LLM call/no-call, and whether state was injected. - base model: accepts conversational style phrasing - basic pipe: `Did you mean 'set premise concise replies'?` - preprocessor pipe: same clarify (near-miss is not rewritten) -- why this is a real win: preprocessor stays reject-first and preserves engine-owned clarify behavior. +- why this matters: preprocessor stays reject-first and preserves engine-owned clarify behavior. **Case 5** @@ -134,4 +137,4 @@ active state, downstream LLM call/no-call, and whether state was injected. - base model: generic “please clarify changes” response - basic pipe: `Did you mean 'change premise to concise replies'?` - preprocessor pipe: same clarify (near-miss is passed through unchanged) -- why this is a real win: near-miss inputs are not canonicalized, so directive semantics stay engine-owned. +- why this matters: near-miss inputs are not canonicalized, so directive semantics stay engine-owned. diff --git a/examples/integrations/openwebui/open_webui_pipe.py b/examples/integrations/openwebui/open_webui_pipe.py index b792c20..bf12711 100644 --- a/examples/integrations/openwebui/open_webui_pipe.py +++ b/examples/integrations/openwebui/open_webui_pipe.py @@ -133,6 +133,25 @@ def _render_compiler_state_block(state: State) -> str: return "\n".join(lines) +def _render_show_state_summary(engine: Engine) -> str: + premise = get_premise_value(engine.state) + use_items = sorted(get_policy_items(engine.state, "use")) + prohibit_items = sorted(get_policy_items(engine.state, "prohibit")) + pending = engine.has_pending_clarification() + + use_text = ", ".join(use_items) if use_items else "none" + prohibit_text = ", ".join(prohibit_items) if prohibit_items else "none" + premise_text = premise if premise is not None else "none" + pending_text = "yes" if pending else "no" + + return ( + f"Premise: {premise_text}\n" + f"Use: {use_text}\n" + f"Prohibit: {prohibit_text}\n" + f"Pending clarification: {pending_text}" + ) + + def _replace_compiler_system_message( messages: list[dict[str, Any]], rendered_state_block: str, @@ -571,6 +590,9 @@ async def pipe( engine.import_checkpoint_json(checkpoint) _ENGINES_BY_CHAT_KEY[chat_key] = engine + if latest_user_text.strip().lower() == "show state": + return _render_show_state_summary(engine) + state_before = engine.state logger.debug("pipe: engine_input=%r", latest_user_text) decision = engine.step(latest_user_text) diff --git a/examples/integrations/openwebui/open_webui_pipe_with_preprocessor.py b/examples/integrations/openwebui/open_webui_pipe_with_preprocessor.py index 4f2de8b..86568bd 100644 --- a/examples/integrations/openwebui/open_webui_pipe_with_preprocessor.py +++ b/examples/integrations/openwebui/open_webui_pipe_with_preprocessor.py @@ -147,6 +147,25 @@ def _render_compiler_state_block(state: State) -> str: return "\n".join(lines) +def _render_show_state_summary(engine: Engine) -> str: + premise = get_premise_value(engine.state) + use_items = sorted(get_policy_items(engine.state, "use")) + prohibit_items = sorted(get_policy_items(engine.state, "prohibit")) + pending = engine.has_pending_clarification() + + use_text = ", ".join(use_items) if use_items else "none" + prohibit_text = ", ".join(prohibit_items) if prohibit_items else "none" + premise_text = premise if premise is not None else "none" + pending_text = "yes" if pending else "no" + + return ( + f"Premise: {premise_text}\n" + f"Use: {use_text}\n" + f"Prohibit: {prohibit_text}\n" + f"Pending clarification: {pending_text}" + ) + + def _replace_compiler_system_message( messages: list[dict[str, Any]], rendered_state_block: str, @@ -830,6 +849,9 @@ async def pipe( engine.import_checkpoint_json(checkpoint) _ENGINES_BY_CHAT_KEY[chat_key] = engine + if latest_user_text.strip().lower() == "show state": + return _render_show_state_summary(engine) + state_before = engine.state preprocessd: str | None = None diff --git a/tests/test_openwebui_pipe.py b/tests/test_openwebui_pipe.py index 802cb2e..f40af83 100644 --- a/tests/test_openwebui_pipe.py +++ b/tests/test_openwebui_pipe.py @@ -438,6 +438,105 @@ async def _track_downstream( assert len(forwarded_payloads) == 0 +def test_pipe_show_state_returns_local_summary_and_no_downstream(monkeypatch) -> None: + module = _load_module_with_openwebui_stubs("owui_pipe_show_state", monkeypatch) + module._ENGINES_BY_CHAT_KEY.clear() + module._CHECKPOINTS_BY_CHAT_KEY.clear() + + downstream_calls = 0 + + async def _track_downstream( + _: object, payload: dict[str, object], __: object + ) -> dict[str, object]: + del payload + nonlocal downstream_calls + downstream_calls += 1 + return {"choices": [{"message": {"content": "downstream"}}]} + + module.generate_chat_completion = _track_downstream + + pipe = module.Pipe() + pipe.valves.BASE_MODEL_ID = "base-model" + pipe.valves.SHOW_CONTEXT_COMPILER_TRACE = True + chat_id = "chat-show-state" + + no_pending = asyncio.run( + pipe.pipe( + {"model": "pipe-model", "messages": [{"role": "user", "content": "show state"}]}, + __user__={"id": "u1"}, + __request__=object(), + __chat_id__=chat_id, + ) + ) + assert no_pending == ("Premise: none\nUse: none\nProhibit: none\nPending clarification: no") + + assert downstream_calls == 0 + assert "Context Compiler trace" not in no_pending + + +def test_pipe_show_state_reports_pending_yes(monkeypatch) -> None: + module = _load_module_with_openwebui_stubs("owui_pipe_show_state_pending", monkeypatch) + module._ENGINES_BY_CHAT_KEY.clear() + module._CHECKPOINTS_BY_CHAT_KEY.clear() + + class _PendingEngine: + state = {"premise": None, "policies": {}, "version": 2} + + def has_pending_clarification(self) -> bool: + return True + + def step(self, _: str) -> dict[str, object]: + raise AssertionError("show state should not step engine") + + monkeypatch.setattr(module, "create_engine", lambda: _PendingEngine()) + pipe = module.Pipe() + pipe.valves.BASE_MODEL_ID = "base-model" + + result = asyncio.run( + pipe.pipe( + {"model": "pipe-model", "messages": [{"role": "user", "content": "show state"}]}, + __user__={"id": "u1"}, + __request__=object(), + __chat_id__="chat-show-state-pending", + ) + ) + assert result == "Premise: none\nUse: none\nProhibit: none\nPending clarification: yes" + + +def test_pipe_show_state_non_exact_routes_normally(monkeypatch) -> None: + module = _load_module_with_openwebui_stubs("owui_pipe_show_state_non_exact", monkeypatch) + module._ENGINES_BY_CHAT_KEY.clear() + module._CHECKPOINTS_BY_CHAT_KEY.clear() + + downstream_calls = 0 + + async def _track_downstream( + _: object, payload: dict[str, object], __: object + ) -> dict[str, object]: + del payload + nonlocal downstream_calls + downstream_calls += 1 + return {"choices": [{"message": {"content": "downstream"}}]} + + module.generate_chat_completion = _track_downstream + pipe = module.Pipe() + pipe.valves.BASE_MODEL_ID = "base-model" + + result = asyncio.run( + pipe.pipe( + { + "model": "pipe-model", + "messages": [{"role": "user", "content": "show state please"}], + }, + __user__={"id": "u1"}, + __request__=object(), + __chat_id__="chat-show-state-non-exact", + ) + ) + assert result == {"choices": [{"message": {"content": "downstream"}}]} + assert downstream_calls == 1 + + def test_pipe_near_miss_directives_return_deterministic_clarify_without_downstream( monkeypatch, ) -> None: diff --git a/tests/test_openwebui_preprocessor_pipe.py b/tests/test_openwebui_preprocessor_pipe.py index f87d753..452f3c8 100644 --- a/tests/test_openwebui_preprocessor_pipe.py +++ b/tests/test_openwebui_preprocessor_pipe.py @@ -767,6 +767,122 @@ async def _track_downstream( assert len(forwarded_payloads) == 0 +def test_preprocessor_pipe_show_state_returns_local_summary_and_bypasses_preprocess_and_model( + monkeypatch, +) -> None: + module = _load_module_with_openwebui_stubs("owui_preproc_show_state", monkeypatch) + module._ENGINES_BY_CHAT_KEY.clear() + module._CHECKPOINTS_BY_CHAT_KEY.clear() + + downstream_calls = 0 + preprocess_calls = 0 + + async def _track_downstream( + _: object, payload: dict[str, object], __: object + ) -> dict[str, object]: + del payload + nonlocal downstream_calls + downstream_calls += 1 + return {"choices": [{"message": {"content": "downstream"}}]} + + async def _track_preprocess( + self, *args: object, **kwargs: object + ) -> tuple[str | None, str | None]: + del self, args, kwargs + nonlocal preprocess_calls + preprocess_calls += 1 + return None, None + + monkeypatch.setattr(module, "generate_chat_completion", _track_downstream) + monkeypatch.setattr(module.Pipe, "_preprocess_user_input", _track_preprocess) + + pipe = module.Pipe() + pipe.valves.BASE_MODEL_ID = "base-model" + pipe.valves.PREPROCESSOR_MODEL_ID = "prep-model" + pipe.valves.SHOW_CONTEXT_COMPILER_TRACE = True + chat_id = "chat-preproc-show-state" + + no_pending = asyncio.run( + pipe.pipe( + {"model": "pipe-model", "messages": [{"role": "user", "content": "show state"}]}, + __user__={"id": "u1"}, + __request__=object(), + __chat_id__=chat_id, + ) + ) + assert no_pending == ("Premise: none\nUse: none\nProhibit: none\nPending clarification: no") + + assert downstream_calls == 0 + assert preprocess_calls == 0 + assert "Context Compiler trace" not in no_pending + + +def test_preprocessor_pipe_show_state_reports_pending_yes(monkeypatch) -> None: + module = _load_module_with_openwebui_stubs("owui_preproc_show_state_pending", monkeypatch) + module._ENGINES_BY_CHAT_KEY.clear() + module._CHECKPOINTS_BY_CHAT_KEY.clear() + + class _PendingEngine: + state = {"premise": None, "policies": {}, "version": 2} + + def has_pending_clarification(self) -> bool: + return True + + def step(self, _: str) -> dict[str, object]: + raise AssertionError("show state should not step engine") + + monkeypatch.setattr(module, "create_engine", lambda: _PendingEngine()) + pipe = module.Pipe() + pipe.valves.BASE_MODEL_ID = "base-model" + pipe.valves.PREPROCESSOR_MODEL_ID = "prep-model" + + result = asyncio.run( + pipe.pipe( + {"model": "pipe-model", "messages": [{"role": "user", "content": "show state"}]}, + __user__={"id": "u1"}, + __request__=object(), + __chat_id__="chat-preproc-show-state-pending", + ) + ) + assert result == "Premise: none\nUse: none\nProhibit: none\nPending clarification: yes" + + +def test_preprocessor_pipe_show_state_non_exact_routes_normally(monkeypatch) -> None: + module = _load_module_with_openwebui_stubs("owui_preproc_show_state_non_exact", monkeypatch) + module._ENGINES_BY_CHAT_KEY.clear() + module._CHECKPOINTS_BY_CHAT_KEY.clear() + + downstream_calls = 0 + + async def _track_downstream( + _: object, payload: dict[str, object], __: object + ) -> dict[str, object]: + del payload + nonlocal downstream_calls + downstream_calls += 1 + return {"choices": [{"message": {"content": "downstream"}}]} + + monkeypatch.setattr(module, "generate_chat_completion", _track_downstream) + + pipe = module.Pipe() + pipe.valves.BASE_MODEL_ID = "base-model" + pipe.valves.PREPROCESSOR_MODEL_ID = "prep-model" + + result = asyncio.run( + pipe.pipe( + { + "model": "pipe-model", + "messages": [{"role": "user", "content": "show state please"}], + }, + __user__={"id": "u1"}, + __request__=object(), + __chat_id__="chat-preproc-show-state-non-exact", + ) + ) + assert result == {"choices": [{"message": {"content": "downstream"}}]} + assert downstream_calls >= 1 + + @pytest.mark.parametrize( ("confirmation",), [