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
2 changes: 1 addition & 1 deletion backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ def ping(*args):
# to - both must exist before register_canvas builds the sendMessage
# intent that calls them.
token_counter = register_token_counter(bus)
composer_document = register_composer(bus, token_counter)
composer_document = register_composer(bus, token_counter, settings_manager, notifications_state)

# R4 (doc/QT_REMOVAL_PLAN.md): the agent-dispatch service - one
# AgentDispatcher per session (never a module-level singleton). Bolted
Expand Down
58 changes: 56 additions & 2 deletions backend/composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,13 @@
from typing import Any
from uuid import uuid4

import api_provider

from backend.events import SessionBus
from backend.notifications import NotificationState
from backend.settings import apply_llama_cpp_reasoning_mode, apply_ollama_reasoning_mode
from backend.token_counter import TokenCounterState
from graphlink_licensing import SettingsManager

REASONING_OPTIONS = [
{"id": "thinking", "label": "Thinking Mode (Enable CoT)", "description": "Slower, higher-quality reasoning."},
Expand Down Expand Up @@ -134,14 +139,25 @@ def payload(self) -> dict[str, Any]:
"contextReview": False,
"routeSelection": False,
"modelSelection": False,
"reasoningSelection": True,
"reasoningSelection": api_provider.is_local_ollama_mode() or api_provider.is_local_llama_cpp_mode(),
"settingsShortcut": True,
"cancellation": True,
},
}


def register_composer(bus: SessionBus, token_counter: TokenCounterState) -> ComposerDocument:
def register_composer(
bus: SessionBus,
token_counter: TokenCounterState,
settings_manager: SettingsManager | None = None,
notifications: NotificationState | None = None,
) -> ComposerDocument:
# settings_manager/notifications are optional only so the 2-positional-
# arg call in backend/tests/test_backend_composer.py's make_bus()
# (register_composer(bus, counter)) keeps working unchanged - the real
# (and only) production call site, backend/app.py's _configure_session,
# always passes both (mirroring register_settings's own `notifications`
# optional-param precedent exactly).
document = ComposerDocument()
bus.register_topic("app-composer", document.payload)

Expand All @@ -156,7 +172,45 @@ async def update_draft(text):
await bus.publish("token-counter")

async def set_reasoning_level(level):
# Busy-guard, matching legacy's setReasoningLevel exactly
# (graphlink_composer_bridge.py, ~line 249): a bare no-op - no
# exception, no publish - while a request is in flight. This
# document only ever has two request_state values
# ("idle"/"generating"), already exposed on the wire as
# request.canSend == (request_state == "idle") - reuse that existing
# fact rather than adding a second field for it.
if document.request_state != "idle":
return

# Unchanged existing behavior: raises ComposerError for an
# unrecognized id (see
# test_set_reasoning_level_intent_updates_and_rejects_unknown).
document.set_reasoning_level(level)

# Same Title-Case normalization backend/settings.py's own
# _OLLAMA_REASONING_MODES/_LLAMA_CPP_REASONING_MODES validate
# against, and legacy's own bridge applied. This ternary is TOTAL -
# it always produces "Thinking" or "Quick" - so the shared apply
# functions below never need to re-validate whatever this handler
# passes them.
normalized_mode = "Thinking" if str(level).strip().lower() == "thinking" else "Quick"

if settings_manager is not None:
# Mutually exclusive by construction (api_provider.py) - order
# between the two branches is arbitrary; Ollama-first here
# purely to match backend/settings.py's own file ordering
# (Ollama defined before Llama.cpp throughout that file).
if api_provider.is_local_ollama_mode():
await apply_ollama_reasoning_mode(settings_manager, normalized_mode)
elif api_provider.is_local_llama_cpp_mode():
failure = await apply_llama_cpp_reasoning_mode(settings_manager, normalized_mode)
if failure is not None and notifications is not None:
notifications.show(failure, "error")
await bus.publish("notification")
# else: cloud/API mode - reasoningSelection is already False
# there (see payload() above), so the UI disables this control
# entirely; skip the apply step, never raise.

await publish()

bus.register_intent("app-composer", "updateDraft", update_draft)
Expand Down
150 changes: 95 additions & 55 deletions backend/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,51 @@ def _locked_llama_cpp_settings(manager: SettingsManager) -> dict[str, Any]:
return manager.get_llama_cpp_settings()


async def apply_ollama_reasoning_mode(manager: SettingsManager, mode: str) -> None:
"""Persist `mode` and, if Ollama is still the live provider, re-apply it to
api_provider's module state so the very next chat()/chat_stream() call
picks it up. `mode` MUST already be "Thinking" or "Quick" - this function
does not re-validate it (every caller's own normalization is total, so
there is no path that could hand it anything else). Checked-and-applied
inside the SAME asyncio.to_thread hop as a deliberate race-preemption:
see set_ollama_reasoning_mode's inline comment for why. Shared by this
file's own setOllamaReasoningMode intent (Settings-dialog Ollama page)
and backend/composer.py's setReasoningLevel intent (composer's own
quick-access popover) so both surfaces apply a change identically."""
await asyncio.to_thread(_apply, manager.set_ollama_reasoning_mode, mode)

def _reapply_if_ollama_is_still_the_live_provider() -> None:
if api_provider.is_local_ollama_mode():
api_provider.initialize_local_provider(config.LOCAL_PROVIDER_OLLAMA, {"reasoning_mode": mode})

await asyncio.to_thread(_reapply_if_ollama_is_still_the_live_provider)


async def apply_llama_cpp_reasoning_mode(manager: SettingsManager, mode: str) -> str | None:
"""Same contract as apply_ollama_reasoning_mode, for Llama.cpp. Returns a
human-readable failure message if the live re-apply fails (the mode is
ALREADY persisted regardless - only the live effect is delayed to the
next mode switch/restart), or None on success / when Llama.cpp is not the
active provider. Returns rather than writes a notice directly: this
file's own setLlamaCppReasoningMode intent writes the message into its
page-local llama_notice cell; backend/composer.py's intent has no such
cell and surfaces it through the shared NotificationState banner
instead - that decision belongs to each caller, not to this function."""
await asyncio.to_thread(_apply, manager.set_llama_cpp_reasoning_mode, mode)

def _reapply_if_llama_cpp_is_still_the_live_provider() -> None:
if api_provider.is_local_llama_cpp_mode():
settings = _locked_llama_cpp_settings(manager)
settings["reasoning_mode"] = mode
api_provider.initialize_local_provider(config.LOCAL_PROVIDER_LLAMACPP, settings)

try:
await asyncio.to_thread(_reapply_if_llama_cpp_is_still_the_live_provider)
except Exception as exc: # noqa: BLE001 - no secret in this path (a local file path, not a credential)
return f"Reasoning mode saved, but could not be applied to the live model: {exc}"
return None


def _redact(text: str, secret: Any) -> str:
# Post-review fix: some HTTP client libraries embed request parameters
# (including a rejected API key) directly in exception text - the
Expand Down Expand Up @@ -642,40 +687,37 @@ def _reset() -> None:
await bus.publish("app-settings")

async def set_ollama_reasoning_mode(mode: str):
# Checked and applied inside the SAME to_thread hop, not split across
# an await boundary: a concurrent mode switch (the toolbar's provider
# selector) landing in a gap between a separate check and a later
# apply could otherwise force the live provider back to Ollama after
# the user had already switched away - the same class of
# stale-check-then-await race the R7.4a audit found and fixed
# elsewhere in this file.
# Legacy-parity note (corrected after adversarial review): this live
# re-apply is NOT what legacy's own settings dialog did.
# graphlink_settings_bridge.py's setOllamaReasoningMode called
# _reinitialize_main_window_agent() unconditionally, which just
# rebuilds ChatAgent - it never touched the live
# OLLAMA_REASONING_MODE global api_provider.py's chat dispatch
# actually reads. Legacy's ONLY path that ever updated that global
# was a completely separate control - the composer toolbar's
# setReasoningLevel - which the Settings dialog was never wired to.
# So in real legacy usage, changing reasoning mode via Settings
# persisted the value but left the live think= kwarg stale until the
# next provider-mode switch or restart. This re-apply is a genuine
# fix for that gap, not a port of existing legacy behavior - gated on
# is_local_ollama_mode() so it can't be the mechanism that forces a
# user who already switched to a different provider back onto
# Ollama.
#
# R7.5d: this apply/re-apply sequence is now shared with
# backend/composer.py's own setReasoningLevel intent - see
# apply_ollama_reasoning_mode's own docstring above.
mode = str(mode)
if mode not in _OLLAMA_REASONING_MODES:
return
await asyncio.to_thread(_apply, manager.set_ollama_reasoning_mode, mode)

def _reapply_if_ollama_is_still_the_live_provider() -> None:
# Checked and applied inside the SAME to_thread hop, not split
# across an await boundary: a concurrent mode switch (the
# toolbar's provider selector) landing in a gap between a
# separate check and a later apply could otherwise force the
# live provider back to Ollama after the user had already
# switched away - the same class of stale-check-then-await
# race the R7.4a audit found and fixed elsewhere in this file.
# Legacy-parity note (corrected after adversarial review): this
# live re-apply is NOT what legacy's own settings dialog did.
# graphlink_settings_bridge.py's setOllamaReasoningMode called
# _reinitialize_main_window_agent() unconditionally, which just
# rebuilds ChatAgent - it never touched the live
# OLLAMA_REASONING_MODE global api_provider.py's chat dispatch
# actually reads. Legacy's ONLY path that ever updated that
# global was a completely separate control - the composer
# toolbar's setReasoningLevel - which the Settings dialog was
# never wired to. So in real legacy usage, changing reasoning
# mode via Settings persisted the value but left the live
# think= kwarg stale until the next provider-mode switch or
# restart. This re-apply is a genuine fix for that gap, not a
# port of existing legacy behavior - gated on
# is_local_ollama_mode() so it can't be the mechanism that
# forces a user who already switched to a different provider
# back onto Ollama.
if api_provider.is_local_ollama_mode():
api_provider.initialize_local_provider(config.LOCAL_PROVIDER_OLLAMA, {"reasoning_mode": mode})

await asyncio.to_thread(_reapply_if_ollama_is_still_the_live_provider)
await apply_ollama_reasoning_mode(manager, mode)
await bus.publish("app-settings")

async def set_ollama_model_assignment(task: str, value: str):
Expand Down Expand Up @@ -866,33 +908,31 @@ def _persist() -> None:
await bus.publish("app-settings")

async def set_llama_cpp_reasoning_mode(mode: str):
# Same shape as set_ollama_reasoning_mode's own live re-apply: gated
# on is_local_llama_cpp_mode(), checked and applied inside the SAME
# to_thread hop so a concurrent provider-mode switch can't be
# clobbered back to Llama.cpp. Unlike Ollama's version, this
# genuinely CAN fail: initialize_local_provider's Llama.cpp branch
# re-validates chat_model_path (must still be a real, existing .gguf
# file) every time it runs - if that file was deleted/moved from
# under an already-active session since Llama.cpp was last
# activated, this raises. The mode is already persisted regardless,
# so a failure here only means it takes effect on the next mode
# switch/restart instead of immediately - not a lost setting.
#
# R7.5d: this apply/re-apply sequence is now shared with
# backend/composer.py's own setReasoningLevel intent - see
# apply_llama_cpp_reasoning_mode's own docstring above.
mode = str(mode)
if mode not in _LLAMA_CPP_REASONING_MODES:
return
await asyncio.to_thread(_apply, manager.set_llama_cpp_reasoning_mode, mode)

def _reapply_if_llama_cpp_is_still_the_live_provider() -> None:
# Same shape as set_ollama_reasoning_mode's own live re-apply:
# gated on is_local_llama_cpp_mode(), checked and applied inside
# this SAME to_thread hop so a concurrent provider-mode switch
# can't be clobbered back to Llama.cpp. Unlike Ollama's version,
# this genuinely CAN fail: initialize_local_provider's
# Llama.cpp branch re-validates chat_model_path (must still be
# a real, existing .gguf file) every time it runs - if that
# file was deleted/moved from under an already-active session
# since Llama.cpp was last activated, this raises. The mode is
# already persisted above regardless, so a failure here only
# means it takes effect on the next mode switch/restart instead
# of immediately - not a lost setting.
if api_provider.is_local_llama_cpp_mode():
settings = _locked_llama_cpp_settings(manager)
settings["reasoning_mode"] = mode
api_provider.initialize_local_provider(config.LOCAL_PROVIDER_LLAMACPP, settings)

try:
await asyncio.to_thread(_reapply_if_llama_cpp_is_still_the_live_provider)
except Exception as exc: # noqa: BLE001 - no secret in this path (a local file path, not a credential)
llama_notice["value"] = f"Reasoning mode saved, but could not be applied to the live model: {exc}"
failure = await apply_llama_cpp_reasoning_mode(manager, mode)
if failure is not None:
llama_notice["value"] = failure
# NOTE: on success, do NOT clear llama_notice to "" - this preserves
# the exact pre-existing behavior (today's code never clears it on
# success either); do not "improve" this as a drive-by fix in this
# increment.
await bus.publish("app-settings")

async def set_llama_cpp_chat_format(chat_format: str):
Expand Down
Loading
Loading