From 173fd186887c21d1af6777116260d69df340b928 Mon Sep 17 00:00:00 2001 From: serversdown Date: Tue, 7 Jul 2026 06:53:55 +0000 Subject: [PATCH] feat: cloud-first consolidation routing + graceful backend fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_015yrEb5qpPGv2FjyxrB7LLk --- lyra/dream.py | 17 +++++++++++----- lyra/llm.py | 20 ++++++++++++++++++ lyra/self_state.py | 6 +++--- lyra/thoughts.py | 4 ++-- tests/test_dream.py | 19 +++++++++++++++++ tests/test_llm_bounds.py | 44 ++++++++++++++++++++++++++++++++++++++++ tests/test_reflect.py | 6 +++--- tests/test_thoughts.py | 4 ++-- 8 files changed, 105 insertions(+), 15 deletions(-) diff --git a/lyra/dream.py b/lyra/dream.py index b0a3255..515b186 100644 --- a/lyra/dream.py +++ b/lyra/dream.py @@ -124,11 +124,18 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict: # --- coherence: fold gists up into profile / eras / narrative --- if (force or drives["coherence"] >= THRESHOLD) and not _over_budget(deadline): - profile.rebuild_profile(backend=backend) - era.rebuild_eras(backend=backend) - narrative.rebuild_narrative(backend=backend) - actions.append("integrated knowledge (profile/eras/narrative)") - drives["coherence"] = 0.0 + # A backend hiccup here must not sink the whole pass (reflection still + # deserves to run); log it and move on, leaving coherence unrelieved so a + # later cycle retries. + try: + profile.rebuild_profile(backend=backend) + era.rebuild_eras(backend=backend) + narrative.rebuild_narrative(backend=backend) + actions.append("integrated knowledge (profile/eras/narrative)") + drives["coherence"] = 0.0 + except Exception as exc: + logbus.log("error", "coherence stage failed", error=str(exc)[:200]) + actions.append("coherence stage failed") # Off-hot-path villain identity housekeeping: propose likely same-person # merges for Brian to confirm on the Players page. Never sinks the cycle. try: diff --git a/lyra/llm.py b/lyra/llm.py index 2c5f697..774b4b6 100644 --- a/lyra/llm.py +++ b/lyra/llm.py @@ -92,6 +92,26 @@ def complete(messages: list[Message], backend: Backend = "local", model: str | N return out +def complete_with_fallback(messages: list[Message], backend: Backend, model: str | None = None, + *, fallback: Backend = "cloud", + max_tokens: int | None = None, timeout: float | None = None) -> str: + """`complete()` but if the primary backend errors (e.g. a local GPU that's + powered off or down), retry once on `fallback` (cloud) instead of failing. + Lets local/GPU-routed work (introspection, consolidation) degrade gracefully. + Re-raises if the primary is already the fallback or no cloud key is configured.""" + try: + return complete(messages, backend=backend, model=model, + max_tokens=max_tokens, timeout=timeout) + except Exception as exc: + can_fallback = backend != fallback and (fallback != "cloud" or load().openai_api_key) + if not can_fallback: + raise + logbus.log("info", "llm fell back", primary=backend, to=fallback, error=str(exc)[:80]) + # Drop the primary's model on fallback — let the fallback pick its own default. + return complete(messages, backend=fallback, model=None, + max_tokens=max_tokens, timeout=timeout) + + def chat_call( messages: list, backend: Backend = "cloud", model: str | None = None, tools: list | None = None, diff --git a/lyra/self_state.py b/lyra/self_state.py index 1b02cc3..1b0540e 100644 --- a/lyra/self_state.py +++ b/lyra/self_state.py @@ -317,7 +317,7 @@ def reflect(backend: Backend | None = None, session_id: str | None = None, ) # Step 1 — draft a reflection. - draft = _safe_json(llm.complete( + draft = _safe_json(llm.complete_with_fallback( [{"role": "system", "content": _REFLECT_PROMPT}, {"role": "user", "content": body}], backend=backend, model=model, )) @@ -326,7 +326,7 @@ def reflect(backend: Backend | None = None, session_id: str | None = None, update, critique, revised = draft, None, None if draft: examine_body = body + "\n\nYOUR DRAFT REFLECTION:\n" + json.dumps(draft, indent=2) - revised = _safe_json(llm.complete( + revised = _safe_json(llm.complete_with_fallback( [{"role": "system", "content": _EXAMINE_PROMPT}, {"role": "user", "content": examine_body}], backend=backend, model=model, @@ -417,7 +417,7 @@ def _consolidate_self(backend: Backend | None = None, model: str | None = None, body = ("STABLE ANCHOR (who you are — this holds):\n" + IDENTITY_ANCHOR + "\n\nYOUR RECENT REFLECTIONS (what's actually been on your mind):\n" + "\n".join(f"- {r}" for r in refs)) - out = _safe_json(llm.complete( + out = _safe_json(llm.complete_with_fallback( [{"role": "system", "content": _CONSOLIDATE_PROMPT}, {"role": "user", "content": body}], backend=backend, model=model, )) diff --git a/lyra/thoughts.py b/lyra/thoughts.py index 1abcb17..de2fc4f 100644 --- a/lyra/thoughts.py +++ b/lyra/thoughts.py @@ -414,7 +414,7 @@ def _compose_reachout(title: str, content: str, backend, model) -> str: """Auto-write her a short personal text about a genuinely salient thought she didn't explicitly flag — so the good ones reach Brian, in her voice, not as a thought-dump.""" try: - out = llm.complete( + out = llm.complete_with_fallback( [{"role": "system", "content": _REACHOUT_PROMPT}, {"role": "user", "content": f'Thought "{title}": {content}'}], backend=backend, model=model, @@ -612,7 +612,7 @@ def think(backend: Backend | None = None, force_mode: str | None = None, ) body = f"{time_line}\n\n{inner}{norestate}\n\n{task}" - out = _safe_json(llm.complete( + out = _safe_json(llm.complete_with_fallback( [{"role": "system", "content": _THINK_PROMPT}, {"role": "user", "content": body}], backend=backend, model=model, )) diff --git a/tests/test_dream.py b/tests/test_dream.py index 32e4d7c..d172a4b 100644 --- a/tests/test_dream.py +++ b/tests/test_dream.py @@ -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 diff --git a/tests/test_llm_bounds.py b/tests/test_llm_bounds.py index a92ca96..6c7a317 100644 --- a/tests/test_llm_bounds.py +++ b/tests/test_llm_bounds.py @@ -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 diff --git a/tests/test_reflect.py b/tests/test_reflect.py index 9e14531..c8aee95 100644 --- a/tests/test_reflect.py +++ b/tests/test_reflect.py @@ -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 ' diff --git a/tests/test_thoughts.py b/tests/test_thoughts.py index 794111c..dc388d8 100644 --- a/tests/test_thoughts.py +++ b/tests/test_thoughts.py @@ -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"])