fix(poker): guarantee hand logging — history was conditioning it away
Logging a stated hand was unreliable and got worse mid-session: same model, same
hand, clean history logged 4/4 but the real session's history logged 0/4. Root
cause: memory.recent() rebuilt past turns as "hand -> narration" with the tool
calls stripped (they live in tool_events), so the model's own context became
few-shot examples training it, mid-conversation, to STOP calling tools. Even a
maximal "LOG FIRST, no exceptions" prompt scored 0/5 — it's structural, not wording.
Two-part fix (both, per the system-of-record frame):
- A (guarantee): chat._ensure_hand_logged — on a HAND turn that's Brian's OWN hand,
if the model didn't log it, force record_hand (tool_choice). Guarded to hero hands
(looks_like_hero_hand) so an observed hand is never force-logged as his. Adds
tool_choice passthrough to llm.chat_call; surfaces msg_type on TurnContext.
- B (heal forward): mind._history_with_tools makes each assistant turn's tool calls
visible in reconstructed history ("record_hand -> Hand #62"), so the demonstrated
pattern stops being "hand -> narrate". Recovers natural logging as logs accumulate.
Verified: force guard returns record_hand on the polluted context; full respond_stream
logs Hand #63 end-to-end on a clean session. 7 guard tests; suite 219 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+47
-2
@@ -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 = []
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+41
-3
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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. \
|
||||
|
||||
@@ -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 == []
|
||||
Reference in New Issue
Block a user