Skip to content

Commit dffe7ed

Browse files
committed
feat: add offline demo LLM fallback for console runs
Add a deterministic OfflineLLMClient used when no external API is configured. Wire the bootstrap composition root to fall back safely (typed via LLMClient Protocol). Includes minor ruff-driven formatting fixes in core/tests.
1 parent 8a1f1b4 commit dffe7ed

6 files changed

Lines changed: 71 additions & 21 deletions

File tree

src/rune_companion/cli/bootstrap.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@
2020
from typing import Literal, cast
2121

2222
from ..config import get_settings
23-
from ..core.ports import ChatMessage
23+
from ..core.ports import ChatMessage, LLMClient
2424
from ..core.state import AppState
2525
from ..llm.client import OpenRouterLLMClient
26+
from ..llm.offline import OfflineLLMClient
2627
from ..memory.store import MemoryStore
2728
from ..tasks.task_store import TaskStore
2829
from ..tts.engine import TTSEngine
@@ -50,9 +51,16 @@ def create_initial_state(*, settings=None) -> AppState:
5051

5152
_ensure_local_dirs(settings)
5253

54+
llm_client: LLMClient
55+
try:
56+
llm_client = OpenRouterLLMClient(settings)
57+
except Exception:
58+
# Fallback for demos / local runs without external services.
59+
llm_client = OfflineLLMClient()
60+
5361
state = AppState(
5462
settings=settings,
55-
llm=OpenRouterLLMClient(settings),
63+
llm=llm_client,
5664
tts_engine=TTSEngine(enabled=settings.tts_mode, settings=settings),
5765
memory=MemoryStore(settings.memory_db_path),
5866
task_store=TaskStore(settings.tasks_db_path),

src/rune_companion/cli/commands.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@
1616
CommandEmitter = Callable[[str], None]
1717
CommandHandler4 = Callable[[AppState, list[str], str | None, str | None], str]
1818
CommandHandler5 = Callable[
19-
[AppState, list[str], str | None, str | None, CommandEmitter | None],
20-
str
19+
[AppState, list[str], str | None, str | None, CommandEmitter | None], str
2120
]
2221
CommandHandler = CommandHandler4 | CommandHandler5
2322

src/rune_companion/core/chat.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -357,9 +357,7 @@ def stream_reply(
357357
if state.save_history:
358358
key = _make_key(user_id, room_id)
359359
history = (
360-
state.dialog_histories.setdefault(key, [])
361-
if key is not None
362-
else state.conversation
360+
state.dialog_histories.setdefault(key, []) if key is not None else state.conversation
363361
)
364362
messages_for_llm: list[ChatMessage] = [*history, {"role": "user", "content": user_text}]
365363
else:

src/rune_companion/llm/offline.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# src/rune_companion/llm/offline.py
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Iterable
6+
7+
from ..core.ports import ChatMessage
8+
9+
10+
class OfflineLLMClient:
11+
"""
12+
Offline deterministic LLM client used for demos when no external API is configured.
13+
14+
Behavior:
15+
- Memory controller prompts -> returns {"ops": []}
16+
- Summarizer prompts -> returns "No significant facts."
17+
- Normal chat -> returns a friendly offline demo response
18+
"""
19+
20+
def stream_chat(self, messages: list[ChatMessage], system_prompt: str) -> Iterable[str]:
21+
sp = (system_prompt or "").lower()
22+
23+
# Planner must output JSON that the controller can parse safely.
24+
if "memory/task planner" in sp or "memory controller" in sp or "<plan_json>" in sp:
25+
yield '{"ops":[]}'
26+
return
27+
28+
# Summarizer has a strict fallback phrase.
29+
if "summarization module" in sp or "episodic summarizer" in sp:
30+
yield "No significant facts."
31+
return
32+
33+
# Normal chat: reflect user message, no external calls.
34+
user_text = ""
35+
for m in reversed(messages):
36+
if m["role"] == "user":
37+
user_text = m["content"]
38+
break
39+
40+
yield (
41+
"Offline demo mode: no external LLM is configured.\n"
42+
"Set RUNE_OPENROUTER_API_KEY (and RUNE_LLM_MODELS) to enable real responses.\n\n"
43+
f"You said: {user_text}"
44+
)

tests/fakes.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,14 @@ class FakeMessenger(OutboundMessenger):
3737
"""
3838
Fake OutboundMessenger used by scheduler tests.
3939
"""
40+
4041
sent: list[SentMessage] = field(default_factory=list)
4142

4243
async def send_text(
43-
self,
44-
*,
45-
text: str,
46-
room_id: str | None = None,
47-
to_user_id: str | None = None,
44+
self,
45+
*,
46+
text: str,
47+
room_id: str | None = None,
48+
to_user_id: str | None = None,
4849
) -> None:
4950
self.sent.append(SentMessage(text=text, room_id=room_id, to_user_id=to_user_id))

tests/test_task_scheduler.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def list_runnable_tasks(self, *, now_ts: float, limit: int = 32):
2929
out: list[Task] = []
3030
for t in self.tasks.values():
3131
if t.status in (TaskStatus.PENDING, TaskStatus.ANSWER_RECEIVED) and (
32-
t.due_at is None or t.due_at <= now_ts
32+
t.due_at is None or t.due_at <= now_ts
3333
):
3434
out.append(t)
3535
out.sort(key=lambda x: (x.due_at or x.created_at, x.created_at))
@@ -47,14 +47,14 @@ def update_task_status(self, task_id: int, new_status):
4747
self.tasks[task_id] = replace(t, status=new_status, updated_at=time.time())
4848

4949
def update_task_fields(
50-
self,
51-
task_id: int,
52-
*,
53-
status=None,
54-
due_at=None,
55-
meta=None,
56-
question_text=None,
57-
answer_text=None,
50+
self,
51+
task_id: int,
52+
*,
53+
status=None,
54+
due_at=None,
55+
meta=None,
56+
question_text=None,
57+
answer_text=None,
5858
):
5959
t = self.tasks[task_id]
6060
self.tasks[task_id] = replace(

0 commit comments

Comments
 (0)