diff --git a/lyra/chat.py b/lyra/chat.py index f6b4460..87937b9 100644 --- a/lyra/chat.py +++ b/lyra/chat.py @@ -10,7 +10,7 @@ deliberate) and hands back a ready message list + the active mode. Then: """ from __future__ import annotations -from lyra import config, llm, logbus, memory, mind, modes, summary +from lyra import config, llm, logbus, memory, mind, modes, poker_prompts, summary from lyra import tools as toolkit from lyra.llm import Backend @@ -78,6 +78,43 @@ def _mind_loop(messages, backend: Backend, model: str | None, tool_specs, return reply, tools_run +_FORCE_LOG = ( + "You have not logged Brian's hand yet — and a hand must ALWAYS be recorded, no exceptions. " + "Call record_hand now: pass his ENTIRE hand description as one `shorthand` string." +) + + +def _ensure_hand_logged(messages, user_msg: str, msg_type: str | None, tools_run: list, + backend: Backend, model: str | None, ctx: dict, session_id: str) -> list: + """Guarantee the ledger. If this turn was Brian's OWN hand and the model didn't log it, + force the record_hand call — the log can't be left to the model's discretion, because + mid-session the history few-shot-conditions it to skip logging (see mind._history_with_tools; + even a maximal 'LOG FIRST' prompt scored 0/5 under a polluted history). Guarded to hero + hands so an observed hand is never force-logged as his. Returns forced tool names.""" + if msg_type != "HAND" or backend not in config.load().tool_backends: + return [] + if any(t in ("record_hand", "log_hand") for t in tools_run): + return [] + if not poker_prompts.looks_like_hero_hand(user_msg): + return [] + try: + _, tcs = llm.chat_call( + messages + [{"role": "system", "content": _FORCE_LOG}], + backend=backend, model=model, tools=toolkit.specs(["record_hand"]), + tool_choice={"type": "function", "function": {"name": "record_hand"}}, + ) + except Exception as exc: + logbus.log("error", "forced hand-log failed", session=session_id, error=str(exc)[:160]) + return [] + forced = [] + for tc in (tcs or []): + result = toolkit.dispatch(tc["name"], tc["arguments"], ctx) + memory.add_tool_event(session_id, tc["name"], tc["arguments"], result) + logbus.log("info", "forced hand log", session=session_id, tool=tc["name"], result=result[:80]) + forced.append(tc["name"]) + return forced + + def _voice_pass(messages, draft: str, backend: Backend, model: str | None) -> str: """Mouth: re-render the mind's draft in her voice. Falls back to the draft on failure.""" try: @@ -104,7 +141,8 @@ def respond(session_id: str, user_msg: str, backend: Backend = "cloud", # Persist the user turn before the tool loop so its timestamp precedes any # tool events fired mid-turn (keeps the transcript export in true order). memory.remember(session_id, "user", user_msg) - reply, _ = _mind_loop(messages, backend, model, tool_specs, ctx, session_id) + reply, tools_run = _mind_loop(messages, backend, model, tool_specs, ctx, session_id) + _ensure_hand_logged(messages, user_msg, turn.msg_type, tools_run, backend, model, ctx, session_id) mouth = _mouth_target(cfg, backend, model) if mouth and reply: reply = _voice_pass(messages, reply, *mouth) @@ -139,6 +177,7 @@ def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud", if mouth is None: # No separate voice: stream the mind directly (the original path, unchanged). parts: list[str] = [] + tools_run: list[str] = [] for _ in range(MAX_TOOL_ROUNDS): assistant_msg = None tool_calls = None @@ -161,7 +200,11 @@ def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud", logbus.log("info", "tool call", session=session_id, tool=tc["name"], result=result[:80]) messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result}) _maybe_switch_mode(session_id, tc["name"]) + tools_run.append(tc["name"]) yield ("tool", tc["name"]) + for name in _ensure_hand_logged(messages, user_msg, turn.msg_type, tools_run, + backend, model, ctx, session_id): + yield ("tool", name) reply = "".join(parts) if not reply: reply = _TANGLED @@ -169,6 +212,8 @@ def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud", else: # Mind decides + runs tools (non-streamed); mouth re-voices, streamed. draft, tools_run = _mind_loop(messages, backend, model, tool_specs, ctx, session_id) + tools_run += _ensure_hand_logged(messages, user_msg, turn.msg_type, tools_run, + backend, model, ctx, session_id) for name in tools_run: yield ("tool", name) parts = [] diff --git a/lyra/llm.py b/lyra/llm.py index 774b4b6..9b1229f 100644 --- a/lyra/llm.py +++ b/lyra/llm.py @@ -114,7 +114,7 @@ def complete_with_fallback(messages: list[Message], backend: Backend, model: str def chat_call( messages: list, backend: Backend = "cloud", model: str | None = None, - tools: list | None = None, + tools: list | None = None, tool_choice: str | dict | None = None, ) -> tuple[dict, list | None]: """One chat turn that may request tool calls (OpenAI-style backends only). @@ -136,6 +136,8 @@ def chat_call( kwargs: dict = {"model": mdl, "messages": messages} if tools: kwargs["tools"] = tools + if tool_choice: # e.g. force a specific tool: {"type":"function","function":{"name":...}} + kwargs["tool_choice"] = tool_choice logbus.log("info", "llm call", kind="chat", backend=backend, model=mdl, tok=_approx_tok(messages)) t0 = time.monotonic() msg = client.chat.completions.create(**kwargs).choices[0].message diff --git a/lyra/mind.py b/lyra/mind.py index 91b0261..2caf6d7 100644 --- a/lyra/mind.py +++ b/lyra/mind.py @@ -138,6 +138,33 @@ def _persona_block(user_msg: str, mode: modes.Mode | None, moment: dict | None) return "\n\n".join(p for p in parts if p) +def _tool_mark(e: dict) -> str: + """Compact one-line receipt of a past tool call for the history marker.""" + res = (e.get("result") or "").strip().replace("\n", " ") + return f"{e['tool']} → {res[:60]}" if res else str(e["tool"]) + + +def _history_with_tools(session_id: str, recent: list) -> list[Message]: + """Recent turns, full fidelity — but each assistant turn is prefixed with the tools + it actually ran that turn (record_hand → Hand #62, …). `memory.recent()` stores only + the final reply text, so without this the model's own context reads as a run of + 'hand → narration' with the logging invisible — which few-shot-conditions it, mid + conversation, to stop calling tools (proven: clean history logs 4/4, this stripped + history 0/4). Showing the calls keeps the demonstrated pattern honest.""" + events = memory.tool_events(session_id) if recent else [] + msgs: list[Message] = [] + prev_at = recent[0].created_at if recent else "" + for ex in recent: + content = ex.content + if ex.role == "assistant" and events: + win = [e for e in events if prev_at < (e.get("created_at") or "") <= ex.created_at] + if win: + content = f"⟦tools I ran this turn: {'; '.join(_tool_mark(e) for e in win)}⟧\n{content}" + msgs.append({"role": ex.role, "content": content}) + prev_at = ex.created_at + return msgs + + def build_messages(session_id: str, user_msg: str, mode: modes.Mode | None = None, moment: dict | None = None) -> list[Message]: """Assemble the full, tiered message list for one turn.""" @@ -232,9 +259,11 @@ def build_messages(session_id: str, user_msg: str, if recalled: messages.append(_detail_note(recalled)) - # Tier 3: current session, full fidelity. - for ex in recent: - messages.append({"role": ex.role, "content": ex.content}) + # Tier 3: current session, full fidelity — with each assistant turn's tool calls + # made VISIBLE (see _history_with_tools: without this, history reads as + # "hand → narration" with the logging invisible, and the model few-shot-learns + # to stop calling tools mid-session). + messages.extend(_history_with_tools(session_id, recent)) messages.append({"role": "user", "content": user_msg}) @@ -333,6 +362,7 @@ class TurnContext: mode: modes.Mode | None = None moment: dict = field(default_factory=dict) # perceive fills this in register: str | None = None # route's per-turn register nudge + msg_type: str | None = None # poker-mode message class (compose fills it) messages: list[Message] = field(default_factory=list) @@ -378,6 +408,14 @@ def _route(ctx: TurnContext) -> TurnContext: def _compose(ctx: TurnContext) -> TurnContext: """Assemble the tiered prompt for the voice model.""" ctx.messages = build_messages(ctx.session_id, ctx.user_msg, ctx.mode, moment=ctx.moment) + # Surface the poker message-class so chat can guarantee the ledger (force a hand log + # if the model skipped it). Cheap + pure; mirrors what build_messages classified. + if ctx.mode and ctx.mode.key == "poker_cash": + try: + handles = [r["name"] for r in poker.session_roster()] + except Exception: + handles = [] + ctx.msg_type = poker_prompts.classify(ctx.user_msg, handles) return ctx diff --git a/lyra/poker_prompts.py b/lyra/poker_prompts.py index c9079b4..3020b86 100644 --- a/lyra/poker_prompts.py +++ b/lyra/poker_prompts.py @@ -147,6 +147,15 @@ def classify(user_msg: str, roster_handles=()) -> str: return "CHAT" +def looks_like_hero_hand(user_msg: str) -> bool: + """True when the message is Brian's OWN hand (first-person + real card content) — + the guard for force-logging. Deliberately conservative: an observed hand (a villain + the actor, no I/me/my) returns False so we never force-log someone else's hand as his.""" + msg = (user_msg or "").strip() + low = msg.lower() + return bool(_FIRST_PERSON.search(low)) and _looks_like_hand(low, msg) + + # --- always-on base (poker) ---------------------------------------------- BASE = """You are copiloting Brian's LIVE cash game — at the table with him, a session open. \ diff --git a/tests/test_hand_logging.py b/tests/test_hand_logging.py new file mode 100644 index 0000000..f28ac68 --- /dev/null +++ b/tests/test_hand_logging.py @@ -0,0 +1,97 @@ +"""Reliable hand logging: hero-hand guard, tool-visible history (B), forced log (A). + +Root cause these guard: mid-session, memory.history() rebuilt past turns as +'hand -> narration' with tool calls stripped, few-shot-conditioning the model to +stop logging (clean history logged 4/4, the stripped history 0/4).""" +from __future__ import annotations + +from types import SimpleNamespace + +from lyra import poker_prompts as pp + + +# --- the hero-hand guard (who gets force-logged) -------------------------- + +def test_looks_like_hero_hand_true_for_brians_own_hand(): + assert pp.looks_like_hero_hand("im utg with 2d2s. i raise to $15, btn calls") + assert pp.looks_like_hero_hand("300eff. i call btn w AsQs, flop Qh7c2s, i bet 20 he calls") + + +def test_looks_like_hero_hand_false_for_observed_and_chatter(): + # A villain the actor (no I/me/my) must never be force-logged as Brian's hand. + assert not pp.looks_like_hero_hand("TAG limped A4o in the SB") + assert not pp.looks_like_hero_hand("how's the table looking tonight?") + assert not pp.looks_like_hero_hand("") + + +# --- Fix B: tool calls made visible in reconstructed history -------------- + +def _ex(role, content, at): + return SimpleNamespace(role=role, content=content, created_at=at, id=hash(at)) + + +def test_history_marks_the_assistant_turn_that_logged(monkeypatch): + from lyra import mind, memory + recent = [ + _ex("user", "i have 2d2s utg, flop 2c2hKs, quads", "2026-07-10T18:00:00.000000+00:00"), + _ex("assistant", "Sick cooler.", "2026-07-10T18:00:05.000000+00:00"), + _ex("user", "how am i doing", "2026-07-10T18:01:00.000000+00:00"), + _ex("assistant", "Up a grand.", "2026-07-10T18:01:03.000000+00:00"), + ] + monkeypatch.setattr(memory, "tool_events", lambda sid: [ + {"tool": "record_hand", "result": "Hand #62 logged — UTG 2d2s.", + "created_at": "2026-07-10T18:00:03.000000+00:00"}, + {"tool": "session_state", "result": "net +1000", + "created_at": "2026-07-10T18:01:02.000000+00:00"}, + ]) + msgs = mind._history_with_tools("s1", recent) + # each event is attributed to the assistant turn whose window it falls in + assert "record_hand → Hand #62 logged" in msgs[1]["content"] + assert msgs[1]["content"].endswith("Sick cooler.") + assert "session_state" in msgs[3]["content"] + # user turns are untouched + assert msgs[0]["content"] == recent[0].content + + +def test_history_no_marker_when_no_tools(monkeypatch): + from lyra import mind, memory + monkeypatch.setattr(memory, "tool_events", lambda sid: []) + recent = [_ex("assistant", "just talking", "2026-07-10T18:00:05.000000+00:00")] + assert mind._history_with_tools("s1", recent)[0]["content"] == "just talking" + + +# --- Fix A: force the log when the model skipped a hero hand --------------- + +def _force_setup(monkeypatch, tool_calls): + from lyra import chat + monkeypatch.setattr(chat.llm, "chat_call", + lambda *a, **k: ({"role": "assistant"}, tool_calls)) + dispatched = [] + monkeypatch.setattr(chat.toolkit, "dispatch", + lambda name, args, ctx=None: dispatched.append(name) or "Hand #71 logged.") + monkeypatch.setattr(chat.memory, "add_tool_event", lambda *a, **k: 1) + return chat, dispatched + + +def test_forces_log_on_unlogged_hero_hand(monkeypatch): + chat, dispatched = _force_setup(monkeypatch, [{"id": "1", "name": "record_hand", + "arguments": '{"shorthand":"AsQs..."}'}]) + forced = chat._ensure_hand_logged([], "300eff i call btn w AsQs, i bet 20", "HAND", [], + "cloud", None, {}, "s1") + assert forced == ["record_hand"] and dispatched == ["record_hand"] + + +def test_does_not_force_when_already_logged(monkeypatch): + chat, dispatched = _force_setup(monkeypatch, []) + forced = chat._ensure_hand_logged([], "i have AsQs, i bet", "HAND", ["record_hand"], + "cloud", None, {}, "s1") + assert forced == [] and dispatched == [] + + +def test_does_not_force_non_hand_or_observed(monkeypatch): + chat, dispatched = _force_setup(monkeypatch, []) + # not a HAND turn + assert chat._ensure_hand_logged([], "down to 220", "LOG", [], "cloud", None, {}, "s1") == [] + # HAND-classified but observed (no first person) → never force-logged as his + assert chat._ensure_hand_logged([], "TAG shoved AKo", "HAND", [], "cloud", None, {}, "s1") == [] + assert dispatched == []