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:
2026-07-10 20:48:04 +00:00
parent 366e71a384
commit 96a44365d9
5 changed files with 197 additions and 6 deletions
+41 -3
View File
@@ -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