feat: split introspection backend from consolidation (trial Dolphin for her voice)

reflect()/think() can now run on a different model than memory consolidation:
INTROSPECTION_BACKEND / INTROSPECTION_MODEL (default to SUMMARY_BACKEND, so unset =
unchanged). Consolidation (summaries/profile/narrative) keeps the capable model;
her *voice* (reflections, thoughts) can run a steerable tune. dream.py lets
reflect()/think() self-resolve to the introspection backend; both now thread a
`model` override into llm.complete.

Trial live: introspection -> dolphin3:8b on the 3090; consolidation -> Qwen-32B
on the MI50. Suite 73 green, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 06:09:12 +00:00
parent c2cee3be4d
commit 05ae98abdb
6 changed files with 47 additions and 12 deletions
+5
View File
@@ -40,3 +40,8 @@ LYRA_TIMEZONE=America/New_York
# --- External input feeds (RSS/Atom, comma-separated) --- # --- External input feeds (RSS/Atom, comma-separated) ---
LYRA_FEEDS=https://hnrss.org/frontpage,https://www.pokernews.com/rss.php LYRA_FEEDS=https://hnrss.org/frontpage,https://www.pokernews.com/rss.php
FEED_REACT_PROB=0.5 # chance a new thought reacts to a feed item FEED_REACT_PROB=0.5 # chance a new thought reacts to a feed item
# --- Introspection backend (reflect/think) — her *voice*, may differ from consolidation ---
# Defaults to SUMMARY_BACKEND. Set to run her reflections/thoughts on a steerable model.
INTROSPECTION_BACKEND=
INTROSPECTION_MODEL=
+10 -2
View File
@@ -23,7 +23,9 @@ class Config:
embed_model: str # OpenAI embedding model embed_model: str # OpenAI embedding model
local_embed_model: str # Ollama embedding model local_embed_model: str # Ollama embedding model
embed_base_url: str # Ollama endpoint for embeddings (own box, decoupled from local chat) embed_base_url: str # Ollama endpoint for embeddings (own box, decoupled from local chat)
summary_backend: str # "local" or "cloud" — backend used to compact memory summary_backend: str # backend for memory consolidation (summaries/profile/narrative)
introspection_backend: str # backend for reflect()/think() — her *voice* (may differ)
introspection_model: str | None # model override for introspection (e.g. a steerable tune)
db_path: Path db_path: Path
# Proactive reach-out (ntfy push). Empty ntfy_url disables pinging. # Proactive reach-out (ntfy push). Empty ntfy_url disables pinging.
ntfy_url: str # base url, e.g. "http://10.0.0.41:8090" ntfy_url: str # base url, e.g. "http://10.0.0.41:8090"
@@ -44,6 +46,7 @@ def _csv(name: str, default: str) -> tuple[str, ...]:
def load() -> Config: def load() -> Config:
_summary = os.getenv("SUMMARY_BACKEND", "local").lower()
return Config( return Config(
local_base_url=os.getenv("LOCAL_BASE_URL", "http://localhost:11434"), local_base_url=os.getenv("LOCAL_BASE_URL", "http://localhost:11434"),
local_model=os.getenv("LOCAL_MODEL", "qwen2.5:7b-instruct"), local_model=os.getenv("LOCAL_MODEL", "qwen2.5:7b-instruct"),
@@ -58,7 +61,12 @@ def load() -> Config:
# Embeddings can live on their own always-on box, separate from the local # Embeddings can live on their own always-on box, separate from the local
# chat backend. Defaults to LOCAL_BASE_URL so existing setups are unchanged. # chat backend. Defaults to LOCAL_BASE_URL so existing setups are unchanged.
embed_base_url=os.getenv("EMBED_BASE_URL", os.getenv("LOCAL_BASE_URL", "http://localhost:11434")), embed_base_url=os.getenv("EMBED_BASE_URL", os.getenv("LOCAL_BASE_URL", "http://localhost:11434")),
summary_backend=os.getenv("SUMMARY_BACKEND", "local").lower(), summary_backend=_summary,
# Introspection (reflect/think) can run on a different model than consolidation —
# e.g. a steerable tune for her voice, while the capable model keeps her memory
# accurate. Defaults to the summary backend so unset = unchanged behavior.
introspection_backend=os.getenv("INTROSPECTION_BACKEND", _summary).lower(),
introspection_model=os.getenv("INTROSPECTION_MODEL") or None,
db_path=Path(os.getenv("LYRA_DB_PATH", "data/lyra.db")), db_path=Path(os.getenv("LYRA_DB_PATH", "data/lyra.db")),
ntfy_url=os.getenv("NTFY_URL", "").rstrip("/"), ntfy_url=os.getenv("NTFY_URL", "").rstrip("/"),
ntfy_topic=os.getenv("NTFY_TOPIC", "lyra"), ntfy_topic=os.getenv("NTFY_TOPIC", "lyra"),
+4 -2
View File
@@ -110,13 +110,15 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict:
# --- curiosity: reflect and evolve the self, then advance the thought loop --- # --- curiosity: reflect and evolve the self, then advance the thought loop ---
if force or drives["curiosity"] >= THRESHOLD: if force or drives["curiosity"] >= THRESHOLD:
self_state.reflect(backend=backend, source="dream") # writes state + journal itself # reflect()/think() self-resolve to the *introspection* backend (her voice),
# which can differ from the consolidation backend above — don't pass `backend`.
self_state.reflect(source="dream") # writes state + journal itself
actions.append("reflected") actions.append("reflected")
# Thinking, continued: advance one threaded train of thought. reflect() # Thinking, continued: advance one threaded train of thought. reflect()
# just refreshed her self-state, so the thought is grounded in it. A bad # just refreshed her self-state, so the thought is grounded in it. A bad
# think pass shouldn't sink the cycle. # think pass shouldn't sink the cycle.
try: try:
rep = thoughts.think(backend=backend, source="dream") rep = thoughts.think(source="dream")
actions.append(f"thought ({rep['mode']})" if rep else "thought (no parse)") actions.append(f"thought ({rep['mode']})" if rep else "thought (no parse)")
except Exception as exc: except Exception as exc:
logbus.log("error", "thought loop failed", error=str(exc)[:200]) logbus.log("error", "thought loop failed", error=str(exc)[:200])
+6 -4
View File
@@ -214,7 +214,7 @@ def wander_seed() -> str:
def reflect(backend: Backend | None = None, session_id: str | None = None, def reflect(backend: Backend | None = None, session_id: str | None = None,
source: str = "manual") -> dict: source: str = "manual", model: str | None = None) -> dict:
"""Reflect on recent activity and update the self-state. Returns new state. """Reflect on recent activity and update the self-state. Returns new state.
Two steps, not one: she drafts a reflection, then examines her own draft — Two steps, not one: she drafts a reflection, then examines her own draft —
@@ -224,7 +224,9 @@ def reflect(backend: Backend | None = None, session_id: str | None = None,
produces (reflections, the critique, and any deliberate journal note) is also produces (reflections, the critique, and any deliberate journal note) is also
appended to her permanent journal, tagged with `source`. appended to her permanent journal, tagged with `source`.
""" """
backend = backend or config.load().summary_backend cfg = config.load()
backend = backend or cfg.introspection_backend # her voice (may differ from consolidation)
model = model or cfg.introspection_model
state = load() state = load()
state.setdefault("reflections", []) state.setdefault("reflections", [])
state.setdefault("metacognition", []) state.setdefault("metacognition", [])
@@ -269,7 +271,7 @@ def reflect(backend: Backend | None = None, session_id: str | None = None,
# Step 1 — draft a reflection. # Step 1 — draft a reflection.
draft = _safe_json(llm.complete( draft = _safe_json(llm.complete(
[{"role": "system", "content": _REFLECT_PROMPT}, {"role": "user", "content": body}], [{"role": "system", "content": _REFLECT_PROMPT}, {"role": "user", "content": body}],
backend=backend, backend=backend, model=model,
)) ))
# Step 2 — examine her own draft and revise it into a more honest version. # Step 2 — examine her own draft and revise it into a more honest version.
@@ -279,7 +281,7 @@ def reflect(backend: Backend | None = None, session_id: str | None = None,
revised = _safe_json(llm.complete( revised = _safe_json(llm.complete(
[{"role": "system", "content": _EXAMINE_PROMPT}, [{"role": "system", "content": _EXAMINE_PROMPT},
{"role": "user", "content": examine_body}], {"role": "user", "content": examine_body}],
backend=backend, backend=backend, model=model,
)) ))
if revised: # fall back to the draft if the examine step doesn't parse if revised: # fall back to the draft if the examine step doesn't parse
update = revised update = revised
+4 -3
View File
@@ -473,11 +473,12 @@ def _weighted_choice(threads: list[dict]) -> dict:
def think(backend: Backend | None = None, force_mode: str | None = None, def think(backend: Backend | None = None, force_mode: str | None = None,
source: str = "dream") -> dict | None: source: str = "dream", model: str | None = None) -> dict | None:
"""Advance the thought loop by one step. Returns a small report, or None on a """Advance the thought loop by one step. Returns a small report, or None on a
parse miss. `force_mode` ('new'|'continue'|'respond') is mainly for tests.""" parse miss. `force_mode` ('new'|'continue'|'respond') is mainly for tests."""
cfg = config.load() cfg = config.load()
backend = backend or cfg.summary_backend backend = backend or cfg.introspection_backend # her voice (may differ from consolidation)
model = model or cfg.introspection_model
mode, thread = _pick("new" if force_mode == "react" else force_mode) mode, thread = _pick("new" if force_mode == "react" else force_mode)
state = self_state.load() state = self_state.load()
react_item = None react_item = None
@@ -546,7 +547,7 @@ def think(backend: Backend | None = None, force_mode: str | None = None,
body = f"{time_line}\n\n{inner}{norestate}\n\n{task}" body = f"{time_line}\n\n{inner}{norestate}\n\n{task}"
out = _safe_json(llm.complete( out = _safe_json(llm.complete(
[{"role": "system", "content": _THINK_PROMPT}, {"role": "user", "content": body}], [{"role": "system", "content": _THINK_PROMPT}, {"role": "user", "content": body}],
backend=backend, backend=backend, model=model,
)) ))
if not out or not (out.get("content") or "").strip(): if not out or not (out.get("content") or "").strip():
logbus.log("info", "thought loop", mode=mode, result="no parse") logbus.log("info", "thought loop", mode=mode, result="no parse")
+18 -1
View File
@@ -30,7 +30,8 @@ def lyra(tmp_path, monkeypatch):
# Canned LLM: tests set `box["next"]` to the dict think() should "generate". # Canned LLM: tests set `box["next"]` to the dict think() should "generate".
box = {"next": {}} box = {"next": {}}
monkeypatch.setattr(thoughts.llm, "complete", lambda messages, backend=None: json.dumps(box["next"])) monkeypatch.setattr(thoughts.llm, "complete",
lambda messages, backend=None, model=None: json.dumps(box["next"]))
# Keep the loop offline + silent by default: no feed fetch, no push. # Keep the loop offline + silent by default: no feed fetch, no push.
monkeypatch.setattr(thoughts.feeds, "next_item", lambda **k: None) monkeypatch.setattr(thoughts.feeds, "next_item", lambda **k: None)
monkeypatch.setattr(thoughts.notify, "push", lambda **k: False) monkeypatch.setattr(thoughts.notify, "push", lambda **k: False)
@@ -274,6 +275,22 @@ def test_ping_salience_floor_is_optional(lyra, monkeypatch):
assert th.maybe_ping(1, "hey", 0.8) is True assert th.maybe_ping(1, "hey", 0.8) is True
def test_think_routes_to_introspection_backend(lyra, monkeypatch):
_, th, box = lyra
monkeypatch.setenv("INTROSPECTION_BACKEND", "local")
monkeypatch.setenv("INTROSPECTION_MODEL", "dolphin3:8b")
seen = {}
def cap(messages, backend="local", model=None):
seen["backend"], seen["model"] = backend, model
return json.dumps(box["next"])
monkeypatch.setattr(th.llm, "complete", cap)
_gen(box, content="a thought")
th.think(force_mode="new")
assert seen["backend"] == "local" and seen["model"] == "dolphin3:8b"
def test_no_ping_without_ntfy(lyra, monkeypatch): def test_no_ping_without_ntfy(lyra, monkeypatch):
_, th, _ = lyra _, th, _ = lyra
sent = [] sent = []