From 1f8100e2cae947f8f263b70a6d99fbffb9533eeb Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Wed, 19 Aug 2026 01:18:43 -0400 Subject: [PATCH 1/4] chore: bump OpenWebUI pipe version to 0.10.0 --- python/reference_integrations/openwebui_pipe/open_webui_pipe.py | 2 +- .../openwebui_pipe/open_webui_pipe_with_directive_drafter.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/reference_integrations/openwebui_pipe/open_webui_pipe.py b/python/reference_integrations/openwebui_pipe/open_webui_pipe.py index 42f45d8..cef5dd3 100644 --- a/python/reference_integrations/openwebui_pipe/open_webui_pipe.py +++ b/python/reference_integrations/openwebui_pipe/open_webui_pipe.py @@ -2,7 +2,7 @@ title: Context Compiler Open WebUI Pipe author: rlippmann author_url: https://github.com/rlippmann/context-compiler-example-integrations -version: 0.9.4 +version: 0.10.0 requirements: context-compiler>=0.9.0dev13 Minimal Open WebUI Pipe integration for Context Compiler. diff --git a/python/reference_integrations/openwebui_pipe/open_webui_pipe_with_directive_drafter.py b/python/reference_integrations/openwebui_pipe/open_webui_pipe_with_directive_drafter.py index e4e404a..f0af75b 100644 --- a/python/reference_integrations/openwebui_pipe/open_webui_pipe_with_directive_drafter.py +++ b/python/reference_integrations/openwebui_pipe/open_webui_pipe_with_directive_drafter.py @@ -2,7 +2,7 @@ title: Context Compiler Open WebUI Pipe (Directive Drafter) author: rlippmann author_url: https://github.com/rlippmann/context-compiler-example-integrations -version: 0.9.4 +version: 0.10.0 requirements: context-compiler>=0.9.0dev13, context-compiler-directive-drafter>=0.2.0dev2 Open WebUI integration with Context Compiler directive drafter. From cfbc90b95fd92c052a0ed777b3ecc85ce3a5e1e3 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Wed, 19 Aug 2026 02:05:38 -0400 Subject: [PATCH 2/4] feat: add OpenWebUI HITL approval flow --- .../openwebui_pipe/README.md | 9 +- .../open_webui_pipe_with_directive_drafter.py | 83 ++++++++-- ...t_openwebui_pipe_with_directive_drafter.py | 145 +++++++++++++++--- 3 files changed, 199 insertions(+), 38 deletions(-) diff --git a/python/reference_integrations/openwebui_pipe/README.md b/python/reference_integrations/openwebui_pipe/README.md index d45b6b4..f0db733 100644 --- a/python/reference_integrations/openwebui_pipe/README.md +++ b/python/reference_integrations/openwebui_pipe/README.md @@ -140,6 +140,8 @@ Suggested verification: - Send `please use docker` and confirm either: - the Directive Drafter converts it into a local state update, or - trace shows the turn followed the normal compiler path without a silent state change +- When a canonical directive is drafted, keep the approval response in the conversation and send `y`; confirm the next request returns `State updated.` and `show state` reports `Use: docker`. +- Repeat the draft flow with `n`; confirm the pipe returns `Directive discarded. No state change was applied.` and `show state` remains unchanged. - Send `please use docker`, then `prohibit docker`, and confirm the second request is rejected without creating resumable state - Send `use docker and prohibit peanuts` and confirm the pipe responds locally that multiple directives are not supported and must be submitted separately - Send a normal prompt such as `what should I run?` and confirm trace shows a forwarded turn with compiler state included @@ -148,13 +150,15 @@ Advanced check: - If you have a local proxy or stub, inspect the forwarded request and confirm it contains exactly one `[[cc_state]]` system message reflecting the active state. - Confirm that saved premise appears as `Premise: ...` and saved policy appears as `Use: ...` or `Prohibit: ...`. +- Confirm that the approval response remains in the Open WebUI message history between the draft and the `y`/`n` request. The pipe stores the pending canonical directive in a host-visible conversation marker, restores it as a `CanonicalDirective`, and strips the marker before downstream model calls. ### Optional extra checks If you want a slightly broader manual pass: - verify chat isolation with separate real chat ids -- verify state is lost after restart because these examples do not use external persistence +- verify a pending approval survives chat continuation, including pipe object replacement, while the conversation transcript is retained +- verify this does not imply that authoritative state survives a server restart; engine state is lost after restart because these examples do not use external persistence - verify non-text input is bypassed ### Notes @@ -165,7 +169,8 @@ If you want a slightly broader manual pass: ## Limits -- No durable external persistence +- Pending approval survives chat continuation only when Open WebUI resends the prior assistant message in the conversation transcript +- Authoritative state does not survive a server restart; it remains in-memory because these examples have no durable external persistence - No multi-worker or cross-process guarantees - No Redis, DB, or external storage for checkpoints - No Filters or Pipelines diff --git a/python/reference_integrations/openwebui_pipe/open_webui_pipe_with_directive_drafter.py b/python/reference_integrations/openwebui_pipe/open_webui_pipe_with_directive_drafter.py index f0af75b..cb8b43c 100644 --- a/python/reference_integrations/openwebui_pipe/open_webui_pipe_with_directive_drafter.py +++ b/python/reference_integrations/openwebui_pipe/open_webui_pipe_with_directive_drafter.py @@ -15,8 +15,9 @@ calling `engine.step(...)` Core decision handling remains the same as the base integration. -Failed transitions are rejected for the current request and do not leave -resumable in-memory engine state behind. +Pending approvals are carried in the Open WebUI conversation transcript so +they survive the transition between requests without relying on pipe object +lifetime. """ import inspect @@ -56,7 +57,7 @@ def Field(*, default: Any, description: str = "") -> Any: # type: ignore[no-red Engine, PolicyValue, ) -from context_compiler.grammar import CanonicalDirective +from context_compiler.grammar import CanonicalDirective, decompose_directive from context_compiler_directive_drafter import ( DirectiveDrafter, DraftResult, @@ -68,8 +69,9 @@ def Field(*, default: Any, description: str = "") -> Any: # type: ignore[no-red logger = logging.getLogger(__name__) _CC_MARKER = "[[cc_state]]" +_PENDING_MARKER_PREFIX = "" _ENGINES_BY_CHAT_KEY: dict[str, Engine] = {} -_PENDING_PROPOSALS_BY_CHAT_KEY: dict[str, str] = {} class _EngineSnapshot(TypedDict): @@ -85,8 +87,16 @@ def _is_explicit_rejection(message: str) -> bool: return message.strip().lower() in {"n", "no"} -def _render_proposal_prompt(directive_text: str) -> str: - return f"This is what I think the directive is:\n{directive_text}\nApply it? (y/n)" +def _render_proposal_prompt(directive: CanonicalDirective, chat_key: str) -> str: + marker = json.dumps( + {"chat_key": chat_key, "directive": directive.text}, + sort_keys=True, + separators=(",", ":"), + ) + return ( + f"This is what I think the directive is:\n{directive.text}\nApply it? (y/n)\n" + f"{_PENDING_MARKER_PREFIX}{marker}{_PENDING_MARKER_SUFFIX}" + ) def _resolve_chat_key( @@ -115,6 +125,49 @@ def _extract_latest_user_text(messages: list[dict[str, Any]]) -> str | None: return None +def _extract_pending_directive( + messages: list[dict[str, Any]], chat_key: str +) -> CanonicalDirective | None: + """Restore the latest unresolved approval from Open WebUI chat history.""" + for index in range(len(messages) - 1, -1, -1): + message = messages[index] + if message.get("role") != "assistant": + continue + content = message.get("content") + if not isinstance(content, str): + continue + marker_start = content.rfind(_PENDING_MARKER_PREFIX) + if marker_start < 0: + continue + marker_end = content.find(_PENDING_MARKER_SUFFIX, marker_start) + if marker_end < 0: + continue + # A later assistant response means this marker was already resolved. + if any(item.get("role") == "assistant" for item in messages[index + 1 :]): + continue + payload_text = content[marker_start + len(_PENDING_MARKER_PREFIX) : marker_end] + try: + payload = json.loads(payload_text) + except json.JSONDecodeError: + continue + if not isinstance(payload, dict) or payload.get("chat_key") != chat_key: + continue + directive_text = payload.get("directive") + if not isinstance(directive_text, str): + continue + directive = decompose_directive(directive_text) + if isinstance(directive, CanonicalDirective): + return directive + return None + + +def _strip_pending_marker_from_text(content: str) -> str: + pattern = ( + re.escape(_PENDING_MARKER_PREFIX) + r".*?" + re.escape(_PENDING_MARKER_SUFFIX) + ) + return re.sub(pattern, "", content, flags=re.DOTALL).rstrip() + + def _snapshot_engine_state(engine: Engine) -> _EngineSnapshot: return {"premise": engine.premise, "policies": dict(engine.policies)} @@ -278,7 +331,9 @@ def _strip_trace_blocks_from_messages( msg = dict(message) content = msg.get("content") if isinstance(content, str): - msg["content"] = _strip_trace_block_from_text(content) + msg["content"] = _strip_pending_marker_from_text( + _strip_trace_block_from_text(content) + ) cleaned.append(msg) return cleaned @@ -822,15 +877,14 @@ async def pipe( if latest_user_text.strip().lower() == "show state": return _render_show_state_summary(engine) - pending_proposal = _PENDING_PROPOSALS_BY_CHAT_KEY.get(chat_key) - if pending_proposal is not None: + pending_directive = _extract_pending_directive(messages, chat_key) + if pending_directive is not None: if _is_explicit_approval(latest_user_text): - del _PENDING_PROPOSALS_BY_CHAT_KEY[chat_key] state_before = _snapshot_engine_state(engine) engine_snapshot_json = engine.export_json() - compile_input = pending_proposal + compile_input = pending_directive.text logger.debug("preprocessor: approved_pending_input=%r", compile_input) - decision = engine.step(compile_input) + decision = engine.apply_directive(pending_directive) if decision.kind == DecisionKind.ERROR: kind = DecisionKind.ERROR.value elif decision.kind == DecisionKind.UPDATE: @@ -912,9 +966,7 @@ async def pipe( state_injected=state_injected, ) if _is_explicit_rejection(latest_user_text): - del _PENDING_PROPOSALS_BY_CHAT_KEY[chat_key] return "Directive discarded. No state change was applied." - del _PENDING_PROPOSALS_BY_CHAT_KEY[chat_key] state_before = _snapshot_engine_state(engine) preprocess_error: str | None = None @@ -953,5 +1005,4 @@ async def pipe( ) compile_input = drafted_result.result.text - _PENDING_PROPOSALS_BY_CHAT_KEY[chat_key] = compile_input - return _render_proposal_prompt(compile_input) + return _render_proposal_prompt(drafted_result.result, chat_key) diff --git a/python/tests/test_openwebui_pipe_with_directive_drafter.py b/python/tests/test_openwebui_pipe_with_directive_drafter.py index b4d315b..626a6da 100644 --- a/python/tests/test_openwebui_pipe_with_directive_drafter.py +++ b/python/tests/test_openwebui_pipe_with_directive_drafter.py @@ -89,7 +89,6 @@ def _guarded_import( module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) module._ENGINES_BY_CHAT_KEY.clear() - module._PENDING_PROPOSALS_BY_CHAT_KEY.clear() return module @@ -137,9 +136,10 @@ async def fake_draft(*args, **kwargs): ) ) - assert result == ( + assert result.startswith( "This is what I think the directive is:\nuse docker\nApply it? (y/n)" ) + assert "" _ENGINES_BY_CHAT_KEY: dict[str, Engine] = {} @@ -79,24 +75,16 @@ class _EngineSnapshot(TypedDict): policies: dict[str, PolicyValue] -def _is_explicit_approval(message: str) -> bool: - return message.strip().lower() in {"y", "yes"} - - -def _is_explicit_rejection(message: str) -> bool: - return message.strip().lower() in {"n", "no"} - - -def _render_proposal_prompt(directive: CanonicalDirective, chat_key: str) -> str: - marker = json.dumps( - {"chat_key": chat_key, "directive": directive.text}, - sort_keys=True, - separators=(",", ":"), - ) - return ( - f"This is what I think the directive is:\n{directive.text}\nApply it? (y/n)\n" - f"{_PENDING_MARKER_PREFIX}{marker}{_PENDING_MARKER_SUFFIX}" - ) +def _confirmation_was_accepted(response: object) -> bool: + if isinstance(response, bool): + return response + if isinstance(response, dict): + for key in ("confirmed", "approved", "value"): + if key in response: + return _confirmation_was_accepted(response[key]) + if isinstance(response, str): + return response.strip().lower() in {"y", "yes", "true", "1", "ok"} + return False def _resolve_chat_key( @@ -125,49 +113,6 @@ def _extract_latest_user_text(messages: list[dict[str, Any]]) -> str | None: return None -def _extract_pending_directive( - messages: list[dict[str, Any]], chat_key: str -) -> CanonicalDirective | None: - """Restore the latest unresolved approval from Open WebUI chat history.""" - for index in range(len(messages) - 1, -1, -1): - message = messages[index] - if message.get("role") != "assistant": - continue - content = message.get("content") - if not isinstance(content, str): - continue - marker_start = content.rfind(_PENDING_MARKER_PREFIX) - if marker_start < 0: - continue - marker_end = content.find(_PENDING_MARKER_SUFFIX, marker_start) - if marker_end < 0: - continue - # A later assistant response means this marker was already resolved. - if any(item.get("role") == "assistant" for item in messages[index + 1 :]): - continue - payload_text = content[marker_start + len(_PENDING_MARKER_PREFIX) : marker_end] - try: - payload = json.loads(payload_text) - except json.JSONDecodeError: - continue - if not isinstance(payload, dict) or payload.get("chat_key") != chat_key: - continue - directive_text = payload.get("directive") - if not isinstance(directive_text, str): - continue - directive = decompose_directive(directive_text) - if isinstance(directive, CanonicalDirective): - return directive - return None - - -def _strip_pending_marker_from_text(content: str) -> str: - pattern = ( - re.escape(_PENDING_MARKER_PREFIX) + r".*?" + re.escape(_PENDING_MARKER_SUFFIX) - ) - return re.sub(pattern, "", content, flags=re.DOTALL).rstrip() - - def _snapshot_engine_state(engine: Engine) -> _EngineSnapshot: return {"premise": engine.premise, "policies": dict(engine.policies)} @@ -331,9 +276,7 @@ def _strip_trace_blocks_from_messages( msg = dict(message) content = msg.get("content") if isinstance(content, str): - msg["content"] = _strip_pending_marker_from_text( - _strip_trace_block_from_text(content) - ) + msg["content"] = _strip_trace_block_from_text(content) cleaned.append(msg) return cleaned @@ -804,6 +747,71 @@ async def _forward_passthrough( return normalized_error return response + async def _apply_approved_directive( + self, + directive: CanonicalDirective, + *, + body: dict[str, Any], + user_payload: dict[str, Any], + request: Request, + base_model_id: str | None, + chat_key: str, + original_input: str, + engine: Engine, + ) -> Any: + state_before = _snapshot_engine_state(engine) + engine_snapshot_json = engine.export_json() + compile_input = directive.text + logger.debug("preprocessor: approved_input=%r", compile_input) + decision = engine.apply_directive(directive) + state_after = _snapshot_engine_state(engine) + + if decision.kind == DecisionKind.ERROR: + _ENGINES_BY_CHAT_KEY[chat_key] = _restore_engine_from_snapshot( + engine_snapshot_json + ) + return self._with_trace( + decision.message, + original_input=original_input, + compiler_input=compile_input, + decision=decision, + state_before=state_before, + state_after=state_after, + preprocessor_output=compile_input, + llm_called=False, + ) + if decision.kind == DecisionKind.UPDATE: + return self._with_trace( + "State updated.", + original_input=original_input, + compiler_input=compile_input, + decision=decision, + state_before=state_before, + state_after=state_after, + preprocessor_output=compile_input, + llm_called=False, + ) + + state_injected = "yes" if _has_non_empty_authoritative_state(engine) else "no" + response = await self._forward_passthrough( + body, + user_payload, + request, + base_model_id=base_model_id, + engine=engine, + ) + return self._with_trace( + response, + original_input=original_input, + compiler_input=compile_input, + decision=decision, + state_before=state_before, + state_after=state_after, + preprocessor_output=compile_input, + llm_called=base_model_id is not None, + state_injected=state_injected, + ) + async def pipe( self, body: dict[str, Any], @@ -811,6 +819,7 @@ async def pipe( __request__: Request, __chat_id__: str | None = None, __metadata__: dict[str, Any] | None = None, + __event_call__: Any | None = None, ) -> Any: # Open WebUI integration entrypoint: # 1) extract latest user input @@ -877,97 +886,6 @@ async def pipe( if latest_user_text.strip().lower() == "show state": return _render_show_state_summary(engine) - pending_directive = _extract_pending_directive(messages, chat_key) - if pending_directive is not None: - if _is_explicit_approval(latest_user_text): - state_before = _snapshot_engine_state(engine) - engine_snapshot_json = engine.export_json() - compile_input = pending_directive.text - logger.debug("preprocessor: approved_pending_input=%r", compile_input) - decision = engine.apply_directive(pending_directive) - if decision.kind == DecisionKind.ERROR: - kind = DecisionKind.ERROR.value - elif decision.kind == DecisionKind.UPDATE: - kind = DECISION_UPDATE - else: - kind = DecisionKind.NO_DIRECTIVE.value - logger.debug("preprocessor: decision=%s", kind) - state_after = _snapshot_engine_state(engine) - - if decision.kind == DecisionKind.ERROR: - _ENGINES_BY_CHAT_KEY[chat_key] = _restore_engine_from_snapshot( - engine_snapshot_json - ) - return self._with_trace( - decision.message - if decision.kind == DecisionKind.ERROR - else None or "", - original_input=latest_user_text, - compiler_input=compile_input, - decision=decision, - state_before=state_before, - state_after=state_after, - preprocessor_output=compile_input, - llm_called=False, - ) - if decision.kind == DecisionKind.NO_DIRECTIVE: - state_injected = ( - "yes" if _has_non_empty_authoritative_state(engine) else "no" - ) - response = await self._forward_passthrough( - body, - __user__, - __request__, - base_model_id=base_model_id, - engine=engine, - ) - return self._with_trace( - response, - original_input=latest_user_text, - compiler_input=compile_input, - decision=decision, - state_before=state_before, - state_after=state_after, - preprocessor_output=compile_input, - llm_called=base_model_id is not None, - state_injected=state_injected, - ) - if decision.kind == DecisionKind.UPDATE: - return self._with_trace( - "State updated.", - original_input=latest_user_text, - compiler_input=compile_input, - decision=decision, - state_before=state_before, - state_after=state_after, - preprocessor_output=compile_input, - llm_called=False, - ) - - state_injected = ( - "yes" if _has_non_empty_authoritative_state(engine) else "no" - ) - response = await self._forward_passthrough( - body, - __user__, - __request__, - base_model_id=base_model_id, - engine=engine, - ) - return self._with_trace( - response, - original_input=latest_user_text, - compiler_input=compile_input, - decision=decision, - state_before=state_before, - state_after=state_after, - preprocessor_output=compile_input, - llm_called=base_model_id is not None, - state_injected=state_injected, - ) - if _is_explicit_rejection(latest_user_text): - return "Directive discarded. No state change was applied." - state_before = _snapshot_engine_state(engine) preprocess_error: str | None = None drafted_result, preprocess_error = await self._preprocess_user_input( @@ -1004,5 +922,34 @@ async def pipe( state_injected=state_injected, ) - compile_input = drafted_result.result.text - return _render_proposal_prompt(drafted_result.result, chat_key) + if __event_call__ is None: + return ( + "Context Compiler pipe misconfigured: Open WebUI confirmation support " + "(__event_call__) is required for Directive Drafter approvals." + ) + + approval_response = await __event_call__( + { + "type": "confirmation", + "data": { + "title": "Approve directive", + "message": ( + "This is what I think the directive is:\n" + f"{drafted_result.result.text}\n\nApply it?" + ), + }, + } + ) + if not _confirmation_was_accepted(approval_response): + return "Directive discarded. No state change was applied." + + return await self._apply_approved_directive( + drafted_result.result, + body=body, + user_payload=__user__, + request=__request__, + base_model_id=base_model_id, + chat_key=chat_key, + original_input=latest_user_text, + engine=engine, + ) diff --git a/python/tests/test_openwebui_pipe_with_directive_drafter.py b/python/tests/test_openwebui_pipe_with_directive_drafter.py index 626a6da..46671b3 100644 --- a/python/tests/test_openwebui_pipe_with_directive_drafter.py +++ b/python/tests/test_openwebui_pipe_with_directive_drafter.py @@ -92,7 +92,64 @@ def _guarded_import( return module -def test_canonical_draft_creates_approval_prompt_and_does_not_mutate_state( +def test_native_confirmation_applies_canonical_draft(monkeypatch) -> None: + module = _load_module("owui_with_drafter_native_confirmation", monkeypatch) + + async def fake_draft(*args, **kwargs): + return DraftResult( + source="test", + result=CanonicalDirective( + kind=DirectiveKind.USE_ITEM, + operands=MappingProxyType({"item": "docker"}), + ), + ) + + confirmation_events: list[dict[str, object]] = [] + + async def confirm(event: dict[str, object]) -> bool: + confirmation_events.append(event) + return True + + monkeypatch.setattr(module.Pipe, "_draft_user_input", fake_draft) + pipe = module.Pipe() + pipe.valves.BASE_MODEL_ID = "base-model" + pipe.valves.PREPROCESSOR_MODEL_ID = "prep-model" + chat_id = "chat-native-confirmation" + + result = asyncio.run( + pipe.pipe( + { + "model": "pipe-model", + "messages": [{"role": "user", "content": "please use docker"}], + }, + __user__={"id": "u1"}, + __request__=object(), + __chat_id__=chat_id, + __event_call__=confirm, + ) + ) + + assert result == "State updated." + assert len(confirmation_events) == 1 + assert confirmation_events[0]["type"] == "confirmation" + assert "use docker" in str(confirmation_events[0]) + assert "cc_pending_directive" not in str(confirmation_events[0]) + + show_state = asyncio.run( + pipe.pipe( + { + "model": "pipe-model", + "messages": [{"role": "user", "content": "show state"}], + }, + __user__={"id": "u1"}, + __request__=object(), + __chat_id__=chat_id, + ) + ) + assert show_state == "Premise: none\nUse: docker\nProhibit: none" + + +def test_confirmation_dialog_does_not_write_marker_to_output( monkeypatch, ) -> None: module = _load_module("owui_with_drafter_before_step", monkeypatch) @@ -113,6 +170,9 @@ async def fake_draft(*args, **kwargs): pipe.valves.PREPROCESSOR_MODEL_ID = "prep-model" chat_id = "chat-before-step" + async def confirm(event: dict[str, object]) -> bool: + return False + result = asyncio.run( pipe.pipe( { @@ -122,6 +182,7 @@ async def fake_draft(*args, **kwargs): __user__={"id": "u1"}, __request__=object(), __chat_id__=chat_id, + __event_call__=confirm, ) ) show_state = asyncio.run( @@ -136,14 +197,12 @@ async def fake_draft(*args, **kwargs): ) ) - assert result.startswith( - "This is what I think the directive is:\nuse docker\nApply it? (y/n)" - ) - assert "