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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 = []
|
||||||
|
|||||||
Reference in New Issue
Block a user