Compare commits
2 Commits
d4e203b00c
...
1dea65794b
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dea65794b | |||
| 173fd18688 |
@@ -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):
|
||||
# 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:
|
||||
|
||||
+20
@@ -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,
|
||||
|
||||
+3
-3
@@ -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,
|
||||
))
|
||||
|
||||
+2
-2
@@ -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,
|
||||
))
|
||||
|
||||
+26
-3
@@ -444,9 +444,29 @@ def _running_stats(args: dict, ctx: dict) -> str:
|
||||
return f"{rs['sessions']} sessions, {rs['hours']:g}h, net {rs['net']:+.0f}{hourly}. By stake: {by}"
|
||||
|
||||
|
||||
def _shorthand_from_fields(args: dict) -> str:
|
||||
"""Rebuild a hand description from log_hand-style granular fields. The chat model
|
||||
sometimes calls record_hand with those fields (position/hole_cards/board/streets)
|
||||
and leaves `shorthand` empty — so we reconstruct a parseable description from
|
||||
whatever it did pass, instead of failing on an empty shorthand."""
|
||||
parts = []
|
||||
pos, hole = args.get("position"), args.get("hole_cards")
|
||||
if pos or hole:
|
||||
parts.append(f"Hero {pos or '?'} with {hole or 'unknown'}")
|
||||
for st in ("preflop", "flop", "turn", "river", "showdown"):
|
||||
if args.get(st):
|
||||
parts.append(f"{st.capitalize()}: {args[st]}")
|
||||
if args.get("board"):
|
||||
parts.append(f"Board: {args['board']}")
|
||||
if args.get("result") is not None:
|
||||
parts.append(f"Hero net: {args['result']}")
|
||||
return ". ".join(str(p).strip() for p in parts if str(p).strip())
|
||||
|
||||
|
||||
def _record_hand(args: dict, ctx: dict) -> str:
|
||||
shorthand = (args.get("shorthand") or "").strip() or _shorthand_from_fields(args)
|
||||
out = poker.record_hand(
|
||||
args.get("shorthand") or "", stakes=args.get("stakes"),
|
||||
shorthand, stakes=args.get("stakes"),
|
||||
tag=args.get("tag"), lesson=args.get("lesson"),
|
||||
)
|
||||
if not out["id"]:
|
||||
@@ -759,8 +779,11 @@ TOOLS.update({
|
||||
"record_hand",
|
||||
"Reconstruct a hand from Brian's rough shorthand into a structured, "
|
||||
"replayable hand history. Use when he describes/vomits a hand he wants "
|
||||
"saved or to review. Pass his description verbatim as 'shorthand'.",
|
||||
{"shorthand": {**_S, "description": "Brian's rough description of the hand, verbatim"},
|
||||
"saved or to review. Pass his ENTIRE description as ONE string in `shorthand` "
|
||||
"— do NOT split it into position/board/street fields (that's log_hand). "
|
||||
"`shorthand` is required and must be non-empty.",
|
||||
{"shorthand": {**_S, "description": "Brian's whole hand description as one verbatim "
|
||||
"string, e.g. 'UTG with 9h6h, raise 15, BTN calls, flop 8h7h5s...'"},
|
||||
"stakes": {**_S, "description": "Stakes if known, e.g. '1/3'"},
|
||||
"tag": {**_S, "description": "well_played | leak | cooler | confidence | notable"},
|
||||
"lesson": {**_S, "description": "Takeaway, if he stated one"}},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""record_hand tolerance: recover when the model calls it with log_hand's fields."""
|
||||
from __future__ import annotations
|
||||
|
||||
from lyra import tools
|
||||
|
||||
_GRANULAR = {
|
||||
"position": "UTG", "hole_cards": "9h6h", "board": "8h7h5s 5h Kc",
|
||||
"preflop": "raised to 15, BTN calls", "flop": "bet 25, BTN calls",
|
||||
"turn": "bet 50, BTN raises to 150, call", "river": "check, BTN all in, snap call",
|
||||
"showdown": "BTN shows 55 for quads, hero shows straight flush", "result": 300,
|
||||
"tag": "notable", "lesson": "rare straight flush over quads",
|
||||
}
|
||||
|
||||
|
||||
def test_shorthand_from_fields_builds_a_parseable_description():
|
||||
s = tools._shorthand_from_fields(_GRANULAR)
|
||||
assert "UTG with 9h6h" in s
|
||||
assert "Preflop:" in s and "River:" in s and "Board: 8h7h5s 5h Kc" in s
|
||||
assert "Hero net: 300" in s
|
||||
|
||||
|
||||
def test_record_hand_recovers_from_granular_fields(monkeypatch):
|
||||
# The model called record_hand with log_hand's schema (no `shorthand`). The
|
||||
# handler must reconstruct one and pass it to poker.record_hand, not fail empty.
|
||||
seen = {}
|
||||
|
||||
def fake_record_hand(shorthand, stakes=None, tag=None, lesson=None, backend=None):
|
||||
seen["shorthand"] = shorthand
|
||||
return {"id": 42, "parsed": {"hero_involved": True, "hero_pos": "UTG",
|
||||
"hero_cards": ["9h", "6h"]}, "linked": 0}
|
||||
|
||||
monkeypatch.setattr(tools.poker, "record_hand", fake_record_hand)
|
||||
out = tools.dispatch("record_hand", _GRANULAR, {})
|
||||
assert "UTG with 9h6h" in seen["shorthand"] # reconstructed, not empty
|
||||
assert "#42" in out and "couldn't parse" not in out
|
||||
|
||||
|
||||
def test_record_hand_still_prefers_explicit_shorthand(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake_record_hand(shorthand, stakes=None, tag=None, lesson=None, backend=None):
|
||||
seen["shorthand"] = shorthand
|
||||
return {"id": 7, "parsed": {"hero_involved": True, "hero_pos": "BTN",
|
||||
"hero_cards": ["As", "Ks"]}, "linked": 0}
|
||||
|
||||
monkeypatch.setattr(tools.poker, "record_hand", fake_record_hand)
|
||||
tools.dispatch("record_hand", {"shorthand": "BTN AKs, I open, everyone folds"}, {})
|
||||
assert seen["shorthand"] == "BTN AKs, I open, everyone folds" # verbatim, not rebuilt
|
||||
|
||||
|
||||
def test_record_hand_empty_call_still_fails_gracefully(monkeypatch):
|
||||
monkeypatch.setattr(tools.poker, "record_hand",
|
||||
lambda *a, **k: {"id": None, "parsed": None})
|
||||
out = tools.dispatch("record_hand", {}, {})
|
||||
assert "couldn't parse" in out.lower()
|
||||
@@ -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
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user