Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 26 additions & 9 deletions python/reference_integrations/openwebui_pipe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ shapes the downstream request.
## Files

- `open_webui_pipe.py`: basic integration, no Directive Drafter layer (recommended/default).
- `open_webui_pipe_with_directive_drafter.py`: optional/experimental Directive Drafter layer (heuristic check first, then optional model fallback) before `engine.step(...)`.
- `open_webui_pipe_with_directive_drafter.py`: optional Directive Drafter layer (heuristic check first, then optional model fallback) before `engine.step(...)`.

## Setup

Expand Down Expand Up @@ -119,7 +119,7 @@ Use this pipe when you want the simplest Open WebUI integration path.

Suggested verification:

- Send `use docker` and confirm you get `State updated: Use docker.` with trace showing a local turn
- Send `use docker` and confirm the native OpenWebUI confirmation dialog appears first; after approval, confirm `State updated: Use docker.` with trace showing a local turn
- Send a normal prompt such as `what should I run?` and confirm trace shows a forwarded turn with compiler state included
- Send `use docker`, then `prohibit docker`, and confirm Open WebUI rejects the second request instead of changing state
- Optionally send `show state` and confirm the state summary is returned locally
Expand All @@ -133,13 +133,27 @@ Advanced check:

Use this pipe when you want the same runtime behavior plus Directive Drafter preprocessing.

When the drafter produces a `CanonicalDirective`, the pipe uses Open WebUI's
native `__event_call__` confirmation dialog for HITL approval. The lifecycle is:

1. Directive Drafter drafts a canonical directive.
2. Open WebUI asks the user to confirm the drafted directive.
3. An approved directive is applied through the Context Compiler `Engine` path.
4. A rejected directive is discarded without changing authoritative state.

The confirmation is request-scoped. It remains active while the current
Open WebUI request waits for the user's response, but it is not durable pending
state and does not survive an Open WebUI server restart.

Suggested verification:

- Send `use docker` and confirm you get `State updated: Use docker.` with trace showing a local turn
- Send `set premise to concise replies` and confirm Open WebUI rejects the request with `Use 'set premise <value>'.`
- 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, confirm the native Open WebUI approval dialog; accepting it returns `State updated.` and `show state` reports `Use: docker`.
- Repeat the draft flow and reject it in the native confirmation dialog; 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
Expand All @@ -148,24 +162,28 @@ 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 prompt is a native Open WebUI confirmation dialog and that no approval marker or pending directive is added to the conversation transcript.

### 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 the native confirmation flow does not depend on pipe object lifetime
- verify authoritative engine state is lost after restart because these examples do not use external persistence
- verify non-text input is bypassed

### Notes

- Trace is the easiest way to verify behavior from the Open WebUI chat output.
- Native approval requires an active Open WebUI browser session because `__event_call__` waits for the confirmation response during the current request.
- Forwarded-request inspection is optional and most useful when you already have a local proxy or stub.
- Exact `show state` is a local-state check and does not rely on trace output.

## Limits

- No durable external persistence
- Approval state is held only for the active native confirmation call; no pending approval state is stored in chat content
- 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
Expand Down Expand Up @@ -215,11 +233,10 @@ If you want a slightly broader manual pass:

## Compatibility

Tested target: Open WebUI `v0.8.12`.
Validated at runtime on stock Docker Open WebUI with a real backend model provider.

Compatibility note: OpenWebUI `0.9.x` changed `Users.get_user_by_id` to async.
These examples support both sync (`0.8.x`) and async (`0.9.x`) user lookup.
Tested and validated at runtime on Open WebUI `v0.11.0` with a real Ollama
backend model provider. Both the basic Pipe and Directive Drafter Pipe loaded
and completed live requests, including native confirmation approval and
rejection flows.

## Troubleshooting

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -15,8 +15,8 @@
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 use Open WebUI's native confirmation event and do not add
state to the conversation transcript.
"""

import inspect
Expand Down Expand Up @@ -50,7 +50,6 @@ def Field(*, default: Any, description: str = "") -> Any: # type: ignore[no-red
from context_compiler import (
Decision,
DecisionKind,
DECISION_UPDATE,
POLICY_PROHIBIT,
POLICY_USE,
Engine,
Expand All @@ -69,24 +68,23 @@ def Field(*, default: Any, description: str = "") -> Any: # type: ignore[no-red

_CC_MARKER = "[[cc_state]]"
_ENGINES_BY_CHAT_KEY: dict[str, Engine] = {}
_PENDING_PROPOSALS_BY_CHAT_KEY: dict[str, str] = {}


class _EngineSnapshot(TypedDict):
premise: str | None
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_text: str) -> str:
return f"This is what I think the directive is:\n{directive_text}\nApply it? (y/n)"
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(
Expand Down Expand Up @@ -749,13 +747,79 @@ 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],
__user__: dict[str, Any],
__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
Expand Down Expand Up @@ -822,100 +886,6 @@ 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:
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
logger.debug("preprocessor: approved_pending_input=%r", compile_input)
decision = engine.step(compile_input)
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):
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
drafted_result, preprocess_error = await self._preprocess_user_input(
Expand Down Expand Up @@ -952,6 +922,34 @@ async def pipe(
state_injected=state_injected,
)

compile_input = drafted_result.result.text
_PENDING_PROPOSALS_BY_CHAT_KEY[chat_key] = compile_input
return _render_proposal_prompt(compile_input)
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,
)
Loading