ShubhamSetia commited on
Commit
50cccdf
·
1 Parent(s): c3e33f1

feat: add agentic actor state

Browse files
app.py CHANGED
@@ -44,6 +44,7 @@ EMPTY_STAGE = """
44
  """
45
 
46
  EMPTY_TRANSCRIPT = "No show yet. The transcript will appear here."
 
47
  EMPTY_DIRECTOR_LOG = "No director notes yet."
48
  EMPTY_TRACE = "No trace events yet."
49
  EMPTY_BACKEND = (
@@ -1018,6 +1019,38 @@ body,
1018
  border: none !important;
1019
  box-shadow: none !important;
1020
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1021
  .gradio-container .accordion {
1022
  background: rgba(13, 6, 14, 0.72) !important;
1023
  border: 1px solid rgba(246, 196, 83, 0.22) !important;
@@ -1612,6 +1645,31 @@ def render_transcript(session: TheaterSession | None) -> str:
1612
  return "\n".join(transcript_lines)
1613
 
1614
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1615
  def render_director_log(session: TheaterSession | None) -> str:
1616
  if session is None:
1617
  return EMPTY_DIRECTOR_LOG
@@ -1737,6 +1795,7 @@ def render_outputs(session: TheaterSession | None):
1737
  return (
1738
  render_stage(session),
1739
  render_transcript(session),
 
1740
  render_director_log(session),
1741
  render_trace(session),
1742
  write_trace_json_file(session),
@@ -1764,6 +1823,7 @@ def create_show(
1764
  None,
1765
  EMPTY_STAGE,
1766
  "No premise yet. Add a premise to raise the curtain.",
 
1767
  EMPTY_DIRECTOR_LOG,
1768
  EMPTY_TRACE,
1769
  None,
@@ -1802,6 +1862,7 @@ def reset_show():
1802
  "",
1803
  EMPTY_STAGE,
1804
  EMPTY_TRANSCRIPT,
 
1805
  EMPTY_DIRECTOR_LOG,
1806
  EMPTY_TRACE,
1807
  None,
@@ -1827,6 +1888,7 @@ def advance_one_beat(
1827
  None,
1828
  EMPTY_STAGE,
1829
  "Create a show before running a beat.",
 
1830
  EMPTY_DIRECTOR_LOG,
1831
  EMPTY_TRACE,
1832
  None,
@@ -1851,6 +1913,7 @@ def advance_full_act(
1851
  None,
1852
  EMPTY_STAGE,
1853
  "Create a show before running the full act.",
 
1854
  EMPTY_DIRECTOR_LOG,
1855
  EMPTY_TRACE,
1856
  None,
@@ -1915,6 +1978,7 @@ def throw_audience_prop(
1915
  None,
1916
  EMPTY_STAGE,
1917
  "Create a show before throwing a prop.",
 
1918
  EMPTY_DIRECTOR_LOG,
1919
  EMPTY_TRACE,
1920
  None,
@@ -1939,6 +2003,7 @@ def summon_audience_actor(
1939
  None,
1940
  EMPTY_STAGE,
1941
  "Create a show before summoning an actor.",
 
1942
  EMPTY_DIRECTOR_LOG,
1943
  EMPTY_TRACE,
1944
  None,
@@ -1962,6 +2027,7 @@ def request_audience_finale(
1962
  None,
1963
  EMPTY_STAGE,
1964
  "Create a show before requesting a finale.",
 
1965
  EMPTY_DIRECTOR_LOG,
1966
  EMPTY_TRACE,
1967
  None,
@@ -2101,6 +2167,12 @@ with gr.Blocks(title="AI Puppet Theater") as app:
2101
  interactive=False,
2102
  elem_classes=["transcript-box", "no-field-label"],
2103
  )
 
 
 
 
 
 
2104
  with gr.Accordion("Behind the Curtain", open=False):
2105
  director_output = gr.Textbox(
2106
  value=EMPTY_DIRECTOR_LOG,
@@ -2181,6 +2253,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
2181
  session_state,
2182
  stage_output,
2183
  transcript_output,
 
2184
  director_output,
2185
  trace_output,
2186
  trace_download,
@@ -2200,6 +2273,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
2200
  session_state,
2201
  stage_output,
2202
  transcript_output,
 
2203
  director_output,
2204
  trace_output,
2205
  trace_download,
@@ -2220,6 +2294,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
2220
  session_state,
2221
  stage_output,
2222
  transcript_output,
 
2223
  director_output,
2224
  trace_output,
2225
  trace_download,
@@ -2240,6 +2315,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
2240
  session_state,
2241
  stage_output,
2242
  transcript_output,
 
2243
  director_output,
2244
  trace_output,
2245
  trace_download,
@@ -2260,6 +2336,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
2260
  session_state,
2261
  stage_output,
2262
  transcript_output,
 
2263
  director_output,
2264
  trace_output,
2265
  trace_download,
@@ -2279,6 +2356,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
2279
  session_state,
2280
  stage_output,
2281
  transcript_output,
 
2282
  director_output,
2283
  trace_output,
2284
  trace_download,
@@ -2299,6 +2377,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
2299
  actor_input,
2300
  stage_output,
2301
  transcript_output,
 
2302
  director_output,
2303
  trace_output,
2304
  trace_download,
 
44
  """
45
 
46
  EMPTY_TRANSCRIPT = "No show yet. The transcript will appear here."
47
+ EMPTY_AGENT_STATE = "<div class=\"agent-state-empty\">No agents on stage yet.</div>"
48
  EMPTY_DIRECTOR_LOG = "No director notes yet."
49
  EMPTY_TRACE = "No trace events yet."
50
  EMPTY_BACKEND = (
 
1019
  border: none !important;
1020
  box-shadow: none !important;
1021
  }
1022
+ .agent-state-grid {
1023
+ display: grid;
1024
+ gap: 0.55rem;
1025
+ grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
1026
+ }
1027
+ .agent-state-card {
1028
+ background: rgba(10, 12, 23, 0.72);
1029
+ border: 1px solid rgba(246, 196, 83, 0.22);
1030
+ border-radius: 8px;
1031
+ box-sizing: border-box;
1032
+ padding: 0.62rem 0.68rem;
1033
+ }
1034
+ .agent-state-name {
1035
+ color: #ffd166;
1036
+ font-weight: 800;
1037
+ line-height: 1.2;
1038
+ margin-bottom: 0.28rem;
1039
+ }
1040
+ .agent-state-line {
1041
+ color: #f8efe4;
1042
+ font-size: 0.78rem;
1043
+ line-height: 1.35;
1044
+ overflow-wrap: anywhere;
1045
+ }
1046
+ .agent-state-line strong {
1047
+ color: #d8c6b6;
1048
+ }
1049
+ .agent-state-empty {
1050
+ color: #cbb7a1;
1051
+ font-size: 0.86rem;
1052
+ padding: 0.35rem 0;
1053
+ }
1054
  .gradio-container .accordion {
1055
  background: rgba(13, 6, 14, 0.72) !important;
1056
  border: 1px solid rgba(246, 196, 83, 0.22) !important;
 
1645
  return "\n".join(transcript_lines)
1646
 
1647
 
1648
+ def render_agent_state(session: TheaterSession | None) -> str:
1649
+ if session is None:
1650
+ return EMPTY_AGENT_STATE
1651
+
1652
+ cards: list[str] = []
1653
+ for actor in session.actors:
1654
+ held_props = actor.held_props or ([actor.held_prop] if actor.held_prop else [])
1655
+ held = ", ".join(held_props) if held_props else "none"
1656
+ recent_memory = actor.recent_memory[-1] if actor.recent_memory else "No memory yet."
1657
+ current_goal = actor.current_goal or actor.goal
1658
+ cards.append(
1659
+ f"""
1660
+ <div class="agent-state-card">
1661
+ <div class="agent-state-name">{escape(actor.avatar)} {escape(actor.name)}</div>
1662
+ <div class="agent-state-line"><strong>Mood:</strong> {escape(actor.mood)}</div>
1663
+ <div class="agent-state-line"><strong>Goal:</strong> {escape(current_goal)}</div>
1664
+ <div class="agent-state-line"><strong>Props:</strong> {escape(held)}</div>
1665
+ <div class="agent-state-line"><strong>Secret:</strong> {escape(actor.secret_status)}</div>
1666
+ <div class="agent-state-line"><strong>Memory:</strong> {escape(recent_memory)}</div>
1667
+ </div>
1668
+ """
1669
+ )
1670
+ return f'<div class="agent-state-grid">{"".join(cards)}</div>'
1671
+
1672
+
1673
  def render_director_log(session: TheaterSession | None) -> str:
1674
  if session is None:
1675
  return EMPTY_DIRECTOR_LOG
 
1795
  return (
1796
  render_stage(session),
1797
  render_transcript(session),
1798
+ render_agent_state(session),
1799
  render_director_log(session),
1800
  render_trace(session),
1801
  write_trace_json_file(session),
 
1823
  None,
1824
  EMPTY_STAGE,
1825
  "No premise yet. Add a premise to raise the curtain.",
1826
+ EMPTY_AGENT_STATE,
1827
  EMPTY_DIRECTOR_LOG,
1828
  EMPTY_TRACE,
1829
  None,
 
1862
  "",
1863
  EMPTY_STAGE,
1864
  EMPTY_TRANSCRIPT,
1865
+ EMPTY_AGENT_STATE,
1866
  EMPTY_DIRECTOR_LOG,
1867
  EMPTY_TRACE,
1868
  None,
 
1888
  None,
1889
  EMPTY_STAGE,
1890
  "Create a show before running a beat.",
1891
+ EMPTY_AGENT_STATE,
1892
  EMPTY_DIRECTOR_LOG,
1893
  EMPTY_TRACE,
1894
  None,
 
1913
  None,
1914
  EMPTY_STAGE,
1915
  "Create a show before running the full act.",
1916
+ EMPTY_AGENT_STATE,
1917
  EMPTY_DIRECTOR_LOG,
1918
  EMPTY_TRACE,
1919
  None,
 
1978
  None,
1979
  EMPTY_STAGE,
1980
  "Create a show before throwing a prop.",
1981
+ EMPTY_AGENT_STATE,
1982
  EMPTY_DIRECTOR_LOG,
1983
  EMPTY_TRACE,
1984
  None,
 
2003
  None,
2004
  EMPTY_STAGE,
2005
  "Create a show before summoning an actor.",
2006
+ EMPTY_AGENT_STATE,
2007
  EMPTY_DIRECTOR_LOG,
2008
  EMPTY_TRACE,
2009
  None,
 
2027
  None,
2028
  EMPTY_STAGE,
2029
  "Create a show before requesting a finale.",
2030
+ EMPTY_AGENT_STATE,
2031
  EMPTY_DIRECTOR_LOG,
2032
  EMPTY_TRACE,
2033
  None,
 
2167
  interactive=False,
2168
  elem_classes=["transcript-box", "no-field-label"],
2169
  )
2170
+ with gr.Accordion("Agent State", open=False):
2171
+ agent_state_output = gr.HTML(
2172
+ value=EMPTY_AGENT_STATE,
2173
+ label="Agent State",
2174
+ elem_classes=["no-field-label"],
2175
+ )
2176
  with gr.Accordion("Behind the Curtain", open=False):
2177
  director_output = gr.Textbox(
2178
  value=EMPTY_DIRECTOR_LOG,
 
2253
  session_state,
2254
  stage_output,
2255
  transcript_output,
2256
+ agent_state_output,
2257
  director_output,
2258
  trace_output,
2259
  trace_download,
 
2273
  session_state,
2274
  stage_output,
2275
  transcript_output,
2276
+ agent_state_output,
2277
  director_output,
2278
  trace_output,
2279
  trace_download,
 
2294
  session_state,
2295
  stage_output,
2296
  transcript_output,
2297
+ agent_state_output,
2298
  director_output,
2299
  trace_output,
2300
  trace_download,
 
2315
  session_state,
2316
  stage_output,
2317
  transcript_output,
2318
+ agent_state_output,
2319
  director_output,
2320
  trace_output,
2321
  trace_download,
 
2336
  session_state,
2337
  stage_output,
2338
  transcript_output,
2339
+ agent_state_output,
2340
  director_output,
2341
  trace_output,
2342
  trace_download,
 
2356
  session_state,
2357
  stage_output,
2358
  transcript_output,
2359
+ agent_state_output,
2360
  director_output,
2361
  trace_output,
2362
  trace_download,
 
2377
  actor_input,
2378
  stage_output,
2379
  transcript_output,
2380
+ agent_state_output,
2381
  director_output,
2382
  trace_output,
2383
  trace_download,
puppet_theater/backends.py CHANGED
@@ -372,10 +372,12 @@ def deterministic_actor_response(
372
  prop: str | None,
373
  ) -> ActorResponse:
374
  return ActorResponse(
 
375
  line=_line_for_beat(session, decision, speaker, prop),
376
  emotion=_emotion_for_beat(decision.beat_type),
377
  gesture=_gesture_for_beat(decision.beat_type),
378
  stage_effect=decision.stage_effect or _effect_for_beat(decision.beat_type),
 
379
  tool_request=None,
380
  )
381
 
@@ -518,7 +520,7 @@ def warm_up_openbmb(
518
  if zerogpu.USE_ZEROGPU:
519
  backend._generate_text(
520
  "Return only this JSON: "
521
- '{"line":"Ready.","emotion":"ready","gesture":"wave","stage_effect":"spotlight","tool_request":null}'
522
  )
523
  else:
524
  backend._load()
@@ -571,6 +573,8 @@ def build_actor_line_prompt(
571
  recent_transcript = "\n".join(
572
  f"{beat.speaker}: {beat.line}" for beat in session.transcript[-3:]
573
  ) or "No lines yet."
 
 
574
  prompt = ACTOR_LINE_PROMPT.format(
575
  show_title=session.show_title,
576
  premise=session.premise,
@@ -578,6 +582,12 @@ def build_actor_line_prompt(
578
  beat_type=decision.beat_type,
579
  speaker_name=speaker.name,
580
  speaker_goal=speaker.goal,
 
 
 
 
 
 
581
  speaker_style=speaker.speaking_style,
582
  audience_action=session.latest_audience_action or "None",
583
  latest_prop=prop or session.latest_prop or "None",
@@ -587,7 +597,7 @@ def build_actor_line_prompt(
587
  )
588
  return (
589
  f"{prompt}\nRecent transcript:\n{recent_transcript}\n\n"
590
- "Keep the line under 220 characters. Return JSON only."
591
  )
592
 
593
 
@@ -621,6 +631,7 @@ def parse_actor_output(raw_output: ActorResponse | dict[str, Any] | str) -> tupl
621
  if parsed is None:
622
  return None, "invalid_schema"
623
 
 
624
  try:
625
  response = ActorResponse.model_validate(parsed)
626
  except ValidationError:
@@ -629,12 +640,13 @@ def parse_actor_output(raw_output: ActorResponse | dict[str, Any] | str) -> tupl
629
  if not response.line.strip():
630
  return None, "invalid_empty_line"
631
 
632
- if len(response.line) > MAX_ACTOR_LINE_CHARS:
633
- capped_line = response.line[: MAX_ACTOR_LINE_CHARS - 3].rstrip() + "..."
634
  response = response.model_copy(update={"line": capped_line})
635
- return response, "valid_line_capped"
 
636
 
637
- return response, "valid"
638
 
639
 
640
  def _coerce_actor_output(raw_output: ActorResponse | dict[str, Any] | str) -> ActorResponse | dict[str, Any] | None:
@@ -646,6 +658,14 @@ def _coerce_actor_output(raw_output: ActorResponse | dict[str, Any] | str) -> Ac
646
  text = raw_output.strip()
647
  if not text:
648
  return None
 
 
 
 
 
 
 
 
649
  try:
650
  decoded = json.loads(text)
651
  except json.JSONDecodeError:
@@ -654,6 +674,47 @@ def _coerce_actor_output(raw_output: ActorResponse | dict[str, Any] | str) -> Ac
654
  return None
655
 
656
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
657
  def _elapsed_ms(start_time: float) -> int:
658
  return round((time.perf_counter() - start_time) * 1000)
659
 
@@ -720,10 +781,11 @@ def _line_for_beat(
720
  prop: str | None,
721
  ) -> str:
722
  if prop is not None:
723
- return f"I shall use this {prop} as evidence, a prop, and possibly a tiny emotional support object."
724
  beat_type = decision.beat_type
725
  if beat_type == "setup":
726
- return f"I see it clearly: {session.premise}, and somehow I am in charge."
 
727
  if beat_type == "denial_or_contradiction":
728
  return "Absolutely not. The premise is innocent, which is exactly what makes it suspicious."
729
  if beat_type == "evidence_or_prop":
@@ -737,6 +799,38 @@ def _line_for_beat(
737
  return "Curtain call! We solved nothing, learned everything, and bowed before the wobble got worse."
738
 
739
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
740
  def _emotion_for_beat(beat_type: str) -> str:
741
  return {
742
  "setup": "curious",
 
372
  prop: str | None,
373
  ) -> ActorResponse:
374
  return ActorResponse(
375
+ intent=_intent_for_beat(decision.beat_type, prop),
376
  line=_line_for_beat(session, decision, speaker, prop),
377
  emotion=_emotion_for_beat(decision.beat_type),
378
  gesture=_gesture_for_beat(decision.beat_type),
379
  stage_effect=decision.stage_effect or _effect_for_beat(decision.beat_type),
380
+ memory_update=_memory_for_beat(session, decision, speaker, prop),
381
  tool_request=None,
382
  )
383
 
 
520
  if zerogpu.USE_ZEROGPU:
521
  backend._generate_text(
522
  "Return only this JSON: "
523
+ '{"intent":"Confirm readiness.","line":"Ready.","emotion":"ready","gesture":"wave","stage_effect":"spotlight","memory_update":"Ready for the cue.","tool_request":null}'
524
  )
525
  else:
526
  backend._load()
 
573
  recent_transcript = "\n".join(
574
  f"{beat.speaker}: {beat.line}" for beat in session.transcript[-3:]
575
  ) or "No lines yet."
576
+ recent_memory = "; ".join(speaker.recent_memory[-3:]) or "None"
577
+ held_props = ", ".join(speaker.held_props or ([speaker.held_prop] if speaker.held_prop else [])) or "none"
578
  prompt = ACTOR_LINE_PROMPT.format(
579
  show_title=session.show_title,
580
  premise=session.premise,
 
582
  beat_type=decision.beat_type,
583
  speaker_name=speaker.name,
584
  speaker_goal=speaker.goal,
585
+ speaker_mood=speaker.mood,
586
+ speaker_current_goal=speaker.current_goal or speaker.goal,
587
+ speaker_goal_progress=speaker.goal_progress,
588
+ speaker_held_props=held_props,
589
+ speaker_secret_status=speaker.secret_status,
590
+ speaker_recent_memory=recent_memory,
591
  speaker_style=speaker.speaking_style,
592
  audience_action=session.latest_audience_action or "None",
593
  latest_prop=prop or session.latest_prop or "None",
 
597
  )
598
  return (
599
  f"{prompt}\nRecent transcript:\n{recent_transcript}\n\n"
600
+ "Keep the line under 25 words. Keep intent and memory_update short. Return JSON only."
601
  )
602
 
603
 
 
631
  if parsed is None:
632
  return None, "invalid_schema"
633
 
634
+ parsed, validation_status = _normalize_actor_payload(parsed)
635
  try:
636
  response = ActorResponse.model_validate(parsed)
637
  except ValidationError:
 
640
  if not response.line.strip():
641
  return None, "invalid_empty_line"
642
 
643
+ if len(response.line) > MAX_ACTOR_LINE_CHARS or len(response.line.split()) > 25:
644
+ capped_line = _cap_words(response.line, 25)
645
  response = response.model_copy(update={"line": capped_line})
646
+ capped_status = "valid_line_capped"
647
+ return response, f"{validation_status};{capped_status}" if validation_status != "valid" else capped_status
648
 
649
+ return response, validation_status
650
 
651
 
652
  def _coerce_actor_output(raw_output: ActorResponse | dict[str, Any] | str) -> ActorResponse | dict[str, Any] | None:
 
658
  text = raw_output.strip()
659
  if not text:
660
  return None
661
+ if text.startswith("```"):
662
+ text = text.strip("`")
663
+ if "\n" in text:
664
+ text = text.split("\n", maxsplit=1)[1]
665
+ start = text.find("{")
666
+ end = text.rfind("}")
667
+ if start != -1 and end != -1 and end > start:
668
+ text = text[start : end + 1]
669
  try:
670
  decoded = json.loads(text)
671
  except json.JSONDecodeError:
 
674
  return None
675
 
676
 
677
+ def _normalize_actor_payload(parsed: ActorResponse | dict[str, Any]) -> tuple[ActorResponse | dict[str, Any], str]:
678
+ if isinstance(parsed, ActorResponse):
679
+ return parsed, "valid"
680
+
681
+ normalized = dict(parsed)
682
+ notes: list[str] = []
683
+ if "intent" not in normalized:
684
+ normalized["intent"] = "Respond to the Director's cue."
685
+ notes.append("compat_intent_defaulted")
686
+ if "memory_update" not in normalized:
687
+ normalized["memory_update"] = ""
688
+ notes.append("compat_memory_defaulted")
689
+ if "tool_request" not in normalized:
690
+ normalized["tool_request"] = None
691
+ notes.append("compat_tool_defaulted")
692
+
693
+ if isinstance(normalized.get("line"), str):
694
+ line = " ".join(str(normalized["line"]).strip().split())
695
+ if len(line) > MAX_ACTOR_LINE_CHARS or len(line.split()) > 25:
696
+ normalized["line"] = _cap_words(line, 25)
697
+ notes.append("line_capped")
698
+ if isinstance(normalized.get("intent"), str) and len(str(normalized["intent"])) > 90:
699
+ normalized["intent"] = str(normalized["intent"])[:87].rstrip() + "..."
700
+ notes.append("intent_capped")
701
+ if isinstance(normalized.get("memory_update"), str) and len(str(normalized["memory_update"])) > 140:
702
+ normalized["memory_update"] = str(normalized["memory_update"])[:137].rstrip() + "..."
703
+ notes.append("memory_capped")
704
+
705
+ return normalized, ";".join(notes) if notes else "valid"
706
+
707
+
708
+ def _cap_words(value: str, max_words: int) -> str:
709
+ words = " ".join(value.strip().split()).split()
710
+ capped = " ".join(words[:max_words]).rstrip(" ,;:")
711
+ if len(capped) > MAX_ACTOR_LINE_CHARS:
712
+ capped = capped[: MAX_ACTOR_LINE_CHARS - 3].rstrip()
713
+ if len(words) > max_words or len(value) > MAX_ACTOR_LINE_CHARS:
714
+ return f"{capped}..."
715
+ return capped
716
+
717
+
718
  def _elapsed_ms(start_time: float) -> int:
719
  return round((time.perf_counter() - start_time) * 1000)
720
 
 
781
  prop: str | None,
782
  ) -> str:
783
  if prop is not None:
784
+ return f"This {prop} is evidence, comfort, and possibly our smallest witness."
785
  beat_type = decision.beat_type
786
  if beat_type == "setup":
787
+ premise = _cap_words(session.premise, 12).rstrip(".")
788
+ return f"I see it clearly: {premise}, and somehow I am in charge."
789
  if beat_type == "denial_or_contradiction":
790
  return "Absolutely not. The premise is innocent, which is exactly what makes it suspicious."
791
  if beat_type == "evidence_or_prop":
 
799
  return "Curtain call! We solved nothing, learned everything, and bowed before the wobble got worse."
800
 
801
 
802
+ def _intent_for_beat(beat_type: str, prop: str | None) -> str:
803
+ if prop is not None:
804
+ return f"Make the {prop} matter."
805
+ return {
806
+ "setup": "Establish the scene.",
807
+ "denial_or_contradiction": "Challenge the premise.",
808
+ "evidence_or_prop": "Turn a clue into momentum.",
809
+ "secret_reveal": "Reveal pressure without derailing.",
810
+ "chaos_or_intervention": "React and escalate briefly.",
811
+ "finale": "Close the show cleanly.",
812
+ }[beat_type]
813
+
814
+
815
+ def _memory_for_beat(
816
+ session: TheaterSession,
817
+ decision: DirectorDecision,
818
+ speaker: Actor,
819
+ prop: str | None,
820
+ ) -> str:
821
+ if prop is not None:
822
+ return f"Used {prop} as important evidence."
823
+ if decision.beat_type == "secret_reveal":
824
+ if decision.reveal_secret:
825
+ return "Shared a secret with the audience."
826
+ return "Almost revealed a secret under pressure."
827
+ if decision.beat_type == "finale":
828
+ return "Reached the curtain call."
829
+ if session.latest_audience_action:
830
+ return "Reacted to the audience interruption."
831
+ return f"Advanced the {decision.beat_type.replace('_', ' ')} beat."
832
+
833
+
834
  def _emotion_for_beat(beat_type: str) -> str:
835
  return {
836
  "setup": "curious",
puppet_theater/director.py CHANGED
@@ -459,13 +459,16 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
459
  response = backend_generation.response
460
  beat = Beat(
461
  speaker=speaker.name,
 
462
  line=response.line,
463
  emotion=response.emotion,
464
  gesture=response.gesture,
465
  stage_effect=response.stage_effect or decision.stage_effect,
 
466
  tool_request=response.tool_request,
467
  )
468
  session.transcript.append(beat)
 
469
 
470
  if prop is not None:
471
  session.latest_prop = None
@@ -509,6 +512,35 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
509
  fallback_reason=backend_generation.error,
510
  load_status=backend_generation.load_status,
511
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
512
  if backend_generation.error:
513
  add_trace_event(
514
  session,
@@ -553,6 +585,40 @@ def _actor_by_name(session: TheaterSession, actor_name: str) -> Actor:
553
  return session.actors[session.beat_index % len(session.actors)]
554
 
555
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
556
  def build_director_prompt(session: TheaterSession) -> str:
557
  actor_profiles = "\n".join(
558
  "- "
@@ -691,6 +757,20 @@ def _trace_text(value: str) -> str:
691
  return " ".join(value.split()).replace(":", "-")[:140]
692
 
693
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
694
  def _record_director_backend_status(backend: object, generation: DirectorGeneration) -> None:
695
  if hasattr(backend, "latest_latency_ms"):
696
  backend.latest_latency_ms = generation.latency_ms
 
459
  response = backend_generation.response
460
  beat = Beat(
461
  speaker=speaker.name,
462
+ intent=response.intent,
463
  line=response.line,
464
  emotion=response.emotion,
465
  gesture=response.gesture,
466
  stage_effect=response.stage_effect or decision.stage_effect,
467
+ memory_update=response.memory_update,
468
  tool_request=response.tool_request,
469
  )
470
  session.transcript.append(beat)
471
+ state_update = apply_actor_state_update(session, speaker, response, decision, prop)
472
 
473
  if prop is not None:
474
  session.latest_prop = None
 
512
  fallback_reason=backend_generation.error,
513
  load_status=backend_generation.load_status,
514
  )
515
+ add_trace_event(
516
+ session,
517
+ "actor_intent",
518
+ beat_index=session.beat_index,
519
+ speaker=speaker.name,
520
+ intent=_public_actor_text(response.intent, speaker),
521
+ validation_status=backend_generation.validation_status,
522
+ fallback_used=backend_generation.fallback_used,
523
+ )
524
+ if state_update["memory_update"]:
525
+ add_trace_event(
526
+ session,
527
+ "actor_memory_update",
528
+ beat_index=session.beat_index,
529
+ speaker=speaker.name,
530
+ memory_update=state_update["memory_update"],
531
+ )
532
+ add_trace_event(
533
+ session,
534
+ "actor_state_update",
535
+ beat_index=session.beat_index,
536
+ speaker=speaker.name,
537
+ mood=speaker.mood,
538
+ current_goal=_public_actor_text(speaker.current_goal or "", speaker),
539
+ goal_progress=_public_actor_text(speaker.goal_progress, speaker),
540
+ held_props=list(speaker.held_props),
541
+ secret_status=speaker.secret_status,
542
+ memory_count=len(speaker.recent_memory),
543
+ )
544
  if backend_generation.error:
545
  add_trace_event(
546
  session,
 
585
  return session.actors[session.beat_index % len(session.actors)]
586
 
587
 
588
+ def apply_actor_state_update(
589
+ session: TheaterSession,
590
+ speaker: Actor,
591
+ response: object,
592
+ decision: DirectorDecision,
593
+ prop: str | None,
594
+ ) -> dict[str, str]:
595
+ intent = _short_public_text(getattr(response, "intent", "") or "Respond to the Director's cue.", speaker, 90)
596
+ memory_update = _short_public_text(getattr(response, "memory_update", "") or "", speaker, 140)
597
+ emotion = _short_public_text(getattr(response, "emotion", "") or "focused", speaker, 40)
598
+
599
+ speaker.mood = emotion
600
+ speaker.current_goal = intent
601
+ speaker.goal_progress = f"Beat {session.beat_index}: {intent}"
602
+
603
+ if prop is not None and prop not in speaker.held_props:
604
+ speaker.held_props.append(prop)
605
+ if prop is not None:
606
+ speaker.held_prop = prop
607
+
608
+ if decision.reveal_secret:
609
+ speaker.secret_status = "revealed"
610
+ elif decision.beat_type == "secret_reveal" and speaker.secret_status == "hidden":
611
+ speaker.secret_status = "hinted"
612
+ elif decision.beat_type == "finale" and speaker.secret_status in {"hinted", "revealed"}:
613
+ speaker.secret_status = "resolved"
614
+
615
+ if memory_update:
616
+ speaker.recent_memory.append(memory_update)
617
+ speaker.recent_memory = speaker.recent_memory[-4:]
618
+
619
+ return {"intent": intent, "memory_update": memory_update, "mood": emotion}
620
+
621
+
622
  def build_director_prompt(session: TheaterSession) -> str:
623
  actor_profiles = "\n".join(
624
  "- "
 
757
  return " ".join(value.split()).replace(":", "-")[:140]
758
 
759
 
760
+ def _public_actor_text(value: str, actor: Actor) -> str:
761
+ text = " ".join(value.split())
762
+ if actor.secret and actor.secret_status not in {"revealed", "resolved"}:
763
+ text = text.replace(actor.secret, "[redacted secret]")
764
+ return text[:140]
765
+
766
+
767
+ def _short_public_text(value: str, actor: Actor, max_chars: int) -> str:
768
+ text = _public_actor_text(value, actor)
769
+ if len(text) <= max_chars:
770
+ return text
771
+ return text[: max_chars - 3].rstrip() + "..."
772
+
773
+
774
  def _record_director_backend_status(backend: object, generation: DirectorGeneration) -> None:
775
  if hasattr(backend, "latest_latency_ms"):
776
  backend.latest_latency_ms = generation.latency_ms
puppet_theater/models.py CHANGED
@@ -4,7 +4,7 @@ from uuid import uuid4
4
 
5
  from typing import Any, Literal
6
 
7
- from pydantic import BaseModel, Field, field_validator
8
 
9
 
10
  BeatType = Literal[
@@ -18,20 +18,47 @@ BeatType = Literal[
18
 
19
 
20
  class ActorResponse(BaseModel):
 
21
  line: str = Field(description="Short, stage-ready puppet dialogue.")
22
  emotion: str
23
  gesture: str
24
  stage_effect: str
 
25
  tool_request: str | None = None
26
 
27
- @field_validator("line", "emotion", "gesture", "stage_effect")
28
  @classmethod
29
- def require_text(cls, value: str) -> str:
30
  cleaned = " ".join(value.strip().split())
31
- if not cleaned:
32
  raise ValueError("field must not be empty")
33
  return cleaned
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  @field_validator("tool_request")
36
  @classmethod
37
  def clean_optional_text(cls, value: str | None) -> str | None:
@@ -76,15 +103,23 @@ class Actor:
76
  speaking_style: str
77
  tools: list[str] = field(default_factory=list)
78
  held_prop: str | None = None
 
 
 
 
 
 
79
 
80
 
81
  @dataclass
82
  class Beat:
83
  speaker: str
 
84
  line: str
85
  emotion: str
86
  gesture: str
87
  stage_effect: str
 
88
  tool_request: str | None = None
89
 
90
 
 
4
 
5
  from typing import Any, Literal
6
 
7
+ from pydantic import BaseModel, Field, ValidationInfo, field_validator
8
 
9
 
10
  BeatType = Literal[
 
18
 
19
 
20
  class ActorResponse(BaseModel):
21
+ intent: str = Field(default="Keep the scene moving.", description="Short visible actor intention.")
22
  line: str = Field(description="Short, stage-ready puppet dialogue.")
23
  emotion: str
24
  gesture: str
25
  stage_effect: str
26
+ memory_update: str = Field(default="", description="Short visible memory note from this beat.")
27
  tool_request: str | None = None
28
 
29
+ @field_validator("intent", "line", "emotion", "gesture", "stage_effect", "memory_update")
30
  @classmethod
31
+ def require_text(cls, value: str, info: ValidationInfo) -> str:
32
  cleaned = " ".join(value.strip().split())
33
+ if not cleaned and info.field_name != "memory_update":
34
  raise ValueError("field must not be empty")
35
  return cleaned
36
 
37
+ @field_validator("intent")
38
+ @classmethod
39
+ def keep_intent_short(cls, value: str) -> str:
40
+ if len(value) > 90:
41
+ raise ValueError("intent must be 90 characters or fewer")
42
+ return value
43
+
44
+ @field_validator("line")
45
+ @classmethod
46
+ def keep_line_short(cls, value: str) -> str:
47
+ if not value:
48
+ raise ValueError("line must not be empty")
49
+ if len(value.split()) > 25:
50
+ raise ValueError("line must be 25 words or fewer")
51
+ if len(value) > 220:
52
+ raise ValueError("line must be 220 characters or fewer")
53
+ return value
54
+
55
+ @field_validator("memory_update")
56
+ @classmethod
57
+ def keep_memory_short(cls, value: str) -> str:
58
+ if len(value) > 140:
59
+ raise ValueError("memory_update must be 140 characters or fewer")
60
+ return value
61
+
62
  @field_validator("tool_request")
63
  @classmethod
64
  def clean_optional_text(cls, value: str | None) -> str | None:
 
103
  speaking_style: str
104
  tools: list[str] = field(default_factory=list)
105
  held_prop: str | None = None
106
+ mood: str = "ready"
107
+ current_goal: str | None = None
108
+ goal_progress: str = "Waiting for the curtain."
109
+ held_props: list[str] = field(default_factory=list)
110
+ secret_status: Literal["hidden", "hinted", "revealed", "resolved"] = "hidden"
111
+ recent_memory: list[str] = field(default_factory=list)
112
 
113
 
114
  @dataclass
115
  class Beat:
116
  speaker: str
117
+ intent: str
118
  line: str
119
  emotion: str
120
  gesture: str
121
  stage_effect: str
122
+ memory_update: str = ""
123
  tool_request: str | None = None
124
 
125
 
puppet_theater/prompts.py CHANGED
@@ -1,18 +1,28 @@
1
  ACTOR_LINE_PROMPT = """You are writing one short puppet line for AI Puppet Theater.
2
 
3
  Return only JSON with these fields:
 
4
  - line: a short, stage-ready spoken line
5
  - emotion: one concise emotion label
6
  - gesture: one concise stage gesture label
7
  - stage_effect: one concise stage effect label
 
8
  - tool_request: null or one concise theatrical tool request
9
 
 
 
10
  Show title: {show_title}
11
  Premise: {premise}
12
  Setting: {setting}
13
  Beat type: {beat_type}
14
  Speaker: {speaker_name}
15
  Speaker goal: {speaker_goal}
 
 
 
 
 
 
16
  Speaker style: {speaker_style}
17
  Director instruction: {director_instruction}
18
  Reveal secret this beat: {reveal_secret}
 
1
  ACTOR_LINE_PROMPT = """You are writing one short puppet line for AI Puppet Theater.
2
 
3
  Return only JSON with these fields:
4
+ - intent: one short visible reason for the actor's next move
5
  - line: a short, stage-ready spoken line
6
  - emotion: one concise emotion label
7
  - gesture: one concise stage gesture label
8
  - stage_effect: one concise stage effect label
9
+ - memory_update: one short visible note the actor should remember, or empty string
10
  - tool_request: null or one concise theatrical tool request
11
 
12
+ Keep line under 25 words. Return JSON only.
13
+
14
  Show title: {show_title}
15
  Premise: {premise}
16
  Setting: {setting}
17
  Beat type: {beat_type}
18
  Speaker: {speaker_name}
19
  Speaker goal: {speaker_goal}
20
+ Speaker mood: {speaker_mood}
21
+ Speaker current goal: {speaker_current_goal}
22
+ Speaker goal progress: {speaker_goal_progress}
23
+ Speaker held props: {speaker_held_props}
24
+ Speaker secret status: {speaker_secret_status}
25
+ Speaker recent memory: {speaker_recent_memory}
26
  Speaker style: {speaker_style}
27
  Director instruction: {director_instruction}
28
  Reveal secret this beat: {reveal_secret}
tests/test_director.py CHANGED
@@ -5,10 +5,12 @@ from puppet_theater.director import (
5
  build_director_prompt,
6
  choose_director_decision,
7
  parse_director_decision,
 
8
  run_full_act,
9
  story_phase,
10
  )
11
  from puppet_theater.models import DirectorDecision
 
12
  from puppet_theater.session import create_show_from_premise, resolve_show_length
13
 
14
 
@@ -77,6 +79,40 @@ def test_invalid_llm_speaker_is_rejected() -> None:
77
  assert status == "invalid_speaker"
78
 
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  def test_fallback_used_when_hf_api_director_cannot_run(monkeypatch) -> None:
81
  session = create_show_from_premise("A kitchen council debates a heroic toaster", director_mode="hf_api")
82
  monkeypatch.setattr(director_module, "get_backend", lambda *args, **kwargs: object())
 
5
  build_director_prompt,
6
  choose_director_decision,
7
  parse_director_decision,
8
+ run_one_beat,
9
  run_full_act,
10
  story_phase,
11
  )
12
  from puppet_theater.models import DirectorDecision
13
+ from puppet_theater.backends import parse_actor_output
14
  from puppet_theater.session import create_show_from_premise, resolve_show_length
15
 
16
 
 
79
  assert status == "invalid_speaker"
80
 
81
 
82
+ def test_old_actor_output_defaults_agentic_fields() -> None:
83
+ response, status = parse_actor_output(
84
+ {
85
+ "line": "The moon denies everything with suspicious confidence.",
86
+ "emotion": "suspicious",
87
+ "gesture": "point_accusingly",
88
+ "stage_effect": "spotlight",
89
+ "tool_request": None,
90
+ }
91
+ )
92
+
93
+ assert response is not None
94
+ assert response.intent == "Respond to the Director's cue."
95
+ assert response.memory_update == ""
96
+ assert "compat_intent_defaulted" in status
97
+ assert "compat_memory_defaulted" in status
98
+
99
+
100
+ def test_actor_state_updates_after_beat() -> None:
101
+ session = create_show_from_premise("A moon detective interrogates a suspicious toaster")
102
+ actor = session.actors[0]
103
+
104
+ run_one_beat(session)
105
+
106
+ assert session.transcript[-1].intent
107
+ assert actor.mood == session.transcript[-1].emotion
108
+ assert actor.current_goal == session.transcript[-1].intent
109
+ assert actor.recent_memory
110
+ event_types = {event["type"] for event in session.trace_events if isinstance(event, dict)}
111
+ assert "actor_intent" in event_types
112
+ assert "actor_memory_update" in event_types
113
+ assert "actor_state_update" in event_types
114
+
115
+
116
  def test_fallback_used_when_hf_api_director_cannot_run(monkeypatch) -> None:
117
  session = create_show_from_premise("A kitchen council debates a heroic toaster", director_mode="hf_api")
118
  monkeypatch.setattr(director_module, "get_backend", lambda *args, **kwargs: object())