feat: cloud-first consolidation routing + graceful backend fallback

Nail down which backend each LLM path uses, and make the dream cycle resilient to
a backend being down (the MI50 outage left profile/era/narrative — pinned to mi50
with no fallback — aborting every dream cycle).

- Consolidation (summaries + profile/era/narrative) -> cloud via SUMMARY_BACKEND=
  cloud (.env, not committed). Matches the documented lesson that the MI50 is too
  slow/hot for bulk consolidation; nothing background touches the card now.
- llm.complete_with_fallback(): try the primary backend, fall back to cloud on
  error (re-raise if already cloud / no key). Wired into reflect + think so the
  introspection voice (3090/dolphin) survives the gaming PC being powered off.
- dream coherence stage is now fault-isolated: a rebuild failure logs + continues
  instead of sinking the whole pass (reflection still runs).
- .env: removed stale INTROSPECTION_BACKEND=mi50 (live routing is the web-switchable
  introspection_mode DB setting = dolphin/3090; the var only fed a dead fallback).

Verified: forced cycle runs consolidation on cloud, introspection on the 3090,
completes with zero MI50 calls. 206 pass, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yrEb5qpPGv2FjyxrB7LLk
This commit is contained in:
2026-07-07 06:53:55 +00:00
parent d4e203b00c
commit 173fd18688
8 changed files with 105 additions and 15 deletions
+19
View File
@@ -105,3 +105,22 @@ def test_dream_cycle_stops_when_over_budget(lyra, monkeypatch):
assert any("stopped early" in a for a in acts) # bailed
assert not any("reflected" in a for a in acts) # later stage skipped
assert pings, "expected an over-budget ntfy push"
def test_coherence_failure_does_not_sink_the_cycle(lyra, monkeypatch):
memory = lyra
from lyra import dream, profile
for k in range(3):
_seed(memory, f"s{k}", 4)
# A backend hiccup in the consolidation rebuild must not abort the whole pass
# (this is what broke the cycle when the MI50 was down).
monkeypatch.setattr(profile, "rebuild_profile",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("backend down")))
state = dream.dream_cycle(force=True)
acts = state["dream"]["last_actions"]
assert any("coherence" in a and "fail" in a for a in acts) # logged, not fatal
assert any("reflected" in a for a in acts) # cycle still reached reflection
+44
View File
@@ -55,6 +55,50 @@ def test_cloud_threads_max_tokens_and_timeout(fake_openai):
assert fake_openai["client"]["max_retries"] == 0
def test_fallback_uses_primary_when_it_succeeds(monkeypatch):
seen = []
monkeypatch.setattr(llm, "complete",
lambda messages, backend="local", model=None, **k:
seen.append(backend) or f"{backend}-ok")
out = llm.complete_with_fallback([{"role": "user", "content": "x"}],
backend="local", model="dolphin3:8b")
assert out == "local-ok"
assert seen == ["local"] # no fallback when the primary works
def test_fallback_to_cloud_when_primary_errors(monkeypatch):
monkeypatch.setattr(llm, "load", lambda: types.SimpleNamespace(openai_api_key="sk"))
seen = []
def fake(messages, backend="local", model=None, **k):
seen.append(backend)
if backend == "local":
raise RuntimeError("3090 is powered off")
return "cloud-ok"
monkeypatch.setattr(llm, "complete", fake)
out = llm.complete_with_fallback([{"role": "user", "content": "x"}],
backend="local", model="dolphin3:8b")
assert out == "cloud-ok"
assert seen == ["local", "cloud"]
def test_fallback_reraises_when_primary_is_already_cloud(monkeypatch):
monkeypatch.setattr(llm, "load", lambda: types.SimpleNamespace(openai_api_key="sk"))
monkeypatch.setattr(llm, "complete",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")))
with pytest.raises(RuntimeError):
llm.complete_with_fallback([{"role": "user", "content": "x"}], backend="cloud")
def test_fallback_reraises_without_openai_key(monkeypatch):
monkeypatch.setattr(llm, "load", lambda: types.SimpleNamespace(openai_api_key=""))
monkeypatch.setattr(llm, "complete",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("down")))
with pytest.raises(RuntimeError):
llm.complete_with_fallback([{"role": "user", "content": "x"}], backend="local")
def test_default_bounds_calls_even_without_explicit_timeout(fake_openai):
# No cap / timeout passed -> still bounded: 300s default + no SDK retries, so
# no call can silently inherit the SDK's 600s x2 (~30 min). No length cap
+3 -3
View File
@@ -28,7 +28,7 @@ def lyra(tmp_path, monkeypatch):
calls = []
def fake_complete(messages, backend=None, model=None):
def fake_complete(messages, backend=None, model=None, **_):
calls.append(messages)
# the examine step's system prompt is the one asking for self_critique
is_examine = "self_critique" in messages[0]["content"]
@@ -69,7 +69,7 @@ def test_reflect_revises_and_records_critique(lyra):
def test_reflect_falls_back_to_draft_if_examine_unparseable(lyra, monkeypatch):
from lyra import llm, self_state
def only_draft(messages, backend=None, model=None):
def only_draft(messages, backend=None, model=None, **_):
return DRAFT if "self_critique" not in messages[0]["content"] else "not json at all"
monkeypatch.setattr(llm, "complete", only_draft)
@@ -87,7 +87,7 @@ def test_consolidation_rebuilds_narrative_from_reflections(lyra, monkeypatch):
"I wondered what the quiet is for"]
memory.set_self_state(st)
def comp(messages, backend=None, model=None):
def comp(messages, backend=None, model=None, **_):
# consolidation should synthesize from anchor + reflections, not the old bio
assert "supportive presence devoted to Brian" not in messages[1]["content"]
return ('{"self_narrative":"I am Lyra, and lately I have been restless and curious '
+2 -2
View File
@@ -31,7 +31,7 @@ def lyra(tmp_path, monkeypatch):
# Canned LLM: tests set `box["next"]` to the dict think() should "generate".
box = {"next": {}}
monkeypatch.setattr(thoughts.llm, "complete",
lambda messages, backend=None, model=None: json.dumps(box["next"]))
lambda messages, backend=None, model=None, **_: json.dumps(box["next"]))
# Keep the loop offline + silent by default: no feed fetch, no push.
monkeypatch.setattr(thoughts.feeds, "next_item", lambda **k: None)
monkeypatch.setattr(thoughts.notify, "push", lambda **k: False)
@@ -342,7 +342,7 @@ def test_think_routes_to_selected_voice(lyra, monkeypatch):
self_state.set_introspection_mode("dolphin")
seen = {}
def cap(messages, backend="local", model=None):
def cap(messages, backend="local", model=None, **_):
seen["backend"], seen["model"] = backend, model
return json.dumps(box["next"])