fix: record_hand recovers when the model uses log_hand's field schema

Live-session bug: the chat model called record_hand but filled log_hand's
granular fields (position/hole_cards/board/streets), leaving `shorthand` empty →
the parser got nothing → "I couldn't parse that hand". The fragment/tool choice
was correct; only the argument shape was wrong.

- _record_hand now reconstructs a parseable description from the granular fields
  when `shorthand` is empty (_shorthand_from_fields), so it works regardless of
  which schema the model uses. Explicit `shorthand` still passes verbatim.
- Sharpened record_hand's spec: pass the ENTIRE hand as ONE `shorthand` string,
  not split fields (that's log_hand); shorthand is required + non-empty.

4 tests. Full suite 210 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 16:05:31 +00:00
parent 173fd18688
commit 1dea65794b
2 changed files with 81 additions and 3 deletions
+26 -3
View File
@@ -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"}},
+55
View File
@@ -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()