ShubhamSetia commited on
Commit
aa14d1f
·
1 Parent(s): 780e79f

feat: add agent trace export

Browse files
README.md CHANGED
@@ -65,6 +65,10 @@ Local deterministic mode and app launch work without `HF_TOKEN`.
65
 
66
  ## Trace export
67
 
68
- Use the collapsed **Trace / Debug** panel to inspect a readable JSON preview of the current show trace. After creating a show, the **Download Trace JSON** button saves a file named `ai-puppet-theater-trace-<session_id>.json`.
69
 
70
- The trace includes session metadata, the premise, title, setting, selected actor and Director backends, model ids when used, Director decisions, actor responses, audience actions, validation status, latency, and fallback metadata. Trace output is sanitized for sharing: it does not include Hugging Face tokens, environment variable values, private local paths, raw tracebacks, actor secrets, or hidden reasoning.
 
 
 
 
 
65
 
66
  ## Trace export
67
 
68
+ Every show keeps a lightweight agent trace in memory for the current Gradio session. The collapsed **Behind the Curtain** panel shows a compact human-readable trace summary with a copy button, plus the Director log. The collapsed **Trace / Debug** panel shows the full JSON preview.
69
 
70
+ After creating a show, use **Download Trace JSON** to save `ai-puppet-theater-trace-<session_id>.json`. The file contains a stable envelope with `app_name`, `trace_version`, `session_id`, `created_at`, premise, title, setting, `show_length_mode`, min/target/max beats, actor backend, Director mode, model id when used, and normalized events.
71
+
72
+ Trace events use a consistent public shape with `event_type`, `timestamp` or `step`, beat/story phase metadata, speaker, Director decisions, actor intent, actor memory updates, tool requests and results, audience actions, backend/model metadata, latency, validation status, fallback usage, and short fallback reasons where available.
73
+
74
+ Traces are sanitized so they are safe to publish later as a dataset: they do not include Hugging Face tokens, environment variable values, private local paths, raw tracebacks, hidden reasoning, or private secrets. Errors are reduced to short summaries and model failures are recorded as fallback events.
app.py CHANGED
@@ -27,7 +27,7 @@ from puppet_theater import (
27
  throw_prop,
28
  warm_up_openbmb,
29
  )
30
- from puppet_theater.trace import add_trace_event, render_trace_json, write_trace_json_file
31
 
32
 
33
  EMPTY_STAGE = """
@@ -53,6 +53,7 @@ EMPTY_TRANSCRIPT = '<div class="conversation-empty">No show yet. The transcript
53
  EMPTY_AGENT_STATE = "<div class=\"agent-state-empty\">No agents on stage yet.</div>"
54
  EMPTY_DIRECTOR_LOG = "No director notes yet."
55
  EMPTY_TRACE = "No trace events yet."
 
56
  EMPTY_BACKEND = (
57
  "Active backend: deterministic\n"
58
  "Director mode: deterministic\n"
@@ -2453,6 +2454,10 @@ def render_trace(session: TheaterSession | None) -> str:
2453
  return render_trace_json(session)
2454
 
2455
 
 
 
 
 
2456
  def normalize_backend_name(backend_name: str | None) -> str:
2457
  return backend_name if backend_name in BACKEND_VALUES else "deterministic"
2458
 
@@ -2574,6 +2579,7 @@ def render_outputs(session: TheaterSession | None, voice_mode: str | None = DEFA
2574
  f'<div class="voice-status">{escape(tts_status)}</div>',
2575
  render_agent_state(session),
2576
  render_director_log(session),
 
2577
  render_trace(session),
2578
  write_trace_json_file(session),
2579
  render_backend_settings(session),
@@ -2606,6 +2612,7 @@ def create_show(
2606
  f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
2607
  EMPTY_AGENT_STATE,
2608
  EMPTY_DIRECTOR_LOG,
 
2609
  EMPTY_TRACE,
2610
  None,
2611
  render_backend_settings(
@@ -2648,6 +2655,7 @@ def reset_show():
2648
  f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
2649
  EMPTY_AGENT_STATE,
2650
  EMPTY_DIRECTOR_LOG,
 
2651
  EMPTY_TRACE,
2652
  None,
2653
  DEFAULT_SHOW_LENGTH,
@@ -2680,6 +2688,7 @@ def advance_one_beat(
2680
  f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
2681
  EMPTY_AGENT_STATE,
2682
  EMPTY_DIRECTOR_LOG,
 
2683
  EMPTY_TRACE,
2684
  None,
2685
  render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
@@ -2709,6 +2718,7 @@ def advance_full_act(
2709
  f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
2710
  EMPTY_AGENT_STATE,
2711
  EMPTY_DIRECTOR_LOG,
 
2712
  EMPTY_TRACE,
2713
  None,
2714
  render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
@@ -2778,6 +2788,7 @@ def throw_audience_prop(
2778
  f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
2779
  EMPTY_AGENT_STATE,
2780
  EMPTY_DIRECTOR_LOG,
 
2781
  EMPTY_TRACE,
2782
  None,
2783
  render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
@@ -2807,6 +2818,7 @@ def summon_audience_actor(
2807
  f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
2808
  EMPTY_AGENT_STATE,
2809
  EMPTY_DIRECTOR_LOG,
 
2810
  EMPTY_TRACE,
2811
  None,
2812
  render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
@@ -2835,6 +2847,7 @@ def request_audience_finale(
2835
  f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
2836
  EMPTY_AGENT_STATE,
2837
  EMPTY_DIRECTOR_LOG,
 
2838
  EMPTY_TRACE,
2839
  None,
2840
  render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
@@ -2886,6 +2899,7 @@ def warm_up_backend(
2886
  return (
2887
  session,
2888
  render_director_log(session),
 
2889
  render_trace(session),
2890
  write_trace_json_file(session),
2891
  render_backend_settings(
@@ -3012,6 +3026,14 @@ with gr.Blocks(title="AI Puppet Theater") as app:
3012
  elem_classes=["no-field-label"],
3013
  )
3014
  with gr.Accordion("Behind the Curtain", open=False):
 
 
 
 
 
 
 
 
3015
  director_output = gr.Textbox(
3016
  value=EMPTY_DIRECTOR_LOG,
3017
  label="Director Log",
@@ -3097,6 +3119,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
3097
  tts_status_output,
3098
  agent_state_output,
3099
  director_output,
 
3100
  trace_output,
3101
  trace_download,
3102
  backend_output,
@@ -3121,6 +3144,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
3121
  tts_status_output,
3122
  agent_state_output,
3123
  director_output,
 
3124
  trace_output,
3125
  trace_download,
3126
  backend_output,
@@ -3146,6 +3170,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
3146
  tts_status_output,
3147
  agent_state_output,
3148
  director_output,
 
3149
  trace_output,
3150
  trace_download,
3151
  backend_output,
@@ -3171,6 +3196,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
3171
  tts_status_output,
3172
  agent_state_output,
3173
  director_output,
 
3174
  trace_output,
3175
  trace_download,
3176
  backend_output,
@@ -3196,6 +3222,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
3196
  tts_status_output,
3197
  agent_state_output,
3198
  director_output,
 
3199
  trace_output,
3200
  trace_download,
3201
  backend_output,
@@ -3220,6 +3247,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
3220
  tts_status_output,
3221
  agent_state_output,
3222
  director_output,
 
3223
  trace_output,
3224
  trace_download,
3225
  backend_output,
@@ -3228,7 +3256,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
3228
  warm_up_button.click(
3229
  warm_up_backend,
3230
  inputs=[session_state, max_new_tokens_input, temperature_input],
3231
- outputs=[session_state, director_output, trace_output, trace_download, backend_output],
3232
  )
3233
  reset_button.click(
3234
  reset_show,
@@ -3244,6 +3272,7 @@ with gr.Blocks(title="AI Puppet Theater") as app:
3244
  tts_status_output,
3245
  agent_state_output,
3246
  director_output,
 
3247
  trace_output,
3248
  trace_download,
3249
  show_length_select,
 
27
  throw_prop,
28
  warm_up_openbmb,
29
  )
30
+ from puppet_theater.trace import add_trace_event, render_trace_json, render_trace_summary, write_trace_json_file
31
 
32
 
33
  EMPTY_STAGE = """
 
53
  EMPTY_AGENT_STATE = "<div class=\"agent-state-empty\">No agents on stage yet.</div>"
54
  EMPTY_DIRECTOR_LOG = "No director notes yet."
55
  EMPTY_TRACE = "No trace events yet."
56
+ EMPTY_TRACE_SUMMARY = "No trace events yet."
57
  EMPTY_BACKEND = (
58
  "Active backend: deterministic\n"
59
  "Director mode: deterministic\n"
 
2454
  return render_trace_json(session)
2455
 
2456
 
2457
+ def render_trace_summary_text(session: TheaterSession | None) -> str:
2458
+ return render_trace_summary(session)
2459
+
2460
+
2461
  def normalize_backend_name(backend_name: str | None) -> str:
2462
  return backend_name if backend_name in BACKEND_VALUES else "deterministic"
2463
 
 
2579
  f'<div class="voice-status">{escape(tts_status)}</div>',
2580
  render_agent_state(session),
2581
  render_director_log(session),
2582
+ render_trace_summary_text(session),
2583
  render_trace(session),
2584
  write_trace_json_file(session),
2585
  render_backend_settings(session),
 
2612
  f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
2613
  EMPTY_AGENT_STATE,
2614
  EMPTY_DIRECTOR_LOG,
2615
+ EMPTY_TRACE_SUMMARY,
2616
  EMPTY_TRACE,
2617
  None,
2618
  render_backend_settings(
 
2655
  f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
2656
  EMPTY_AGENT_STATE,
2657
  EMPTY_DIRECTOR_LOG,
2658
+ EMPTY_TRACE_SUMMARY,
2659
  EMPTY_TRACE,
2660
  None,
2661
  DEFAULT_SHOW_LENGTH,
 
2688
  f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
2689
  EMPTY_AGENT_STATE,
2690
  EMPTY_DIRECTOR_LOG,
2691
+ EMPTY_TRACE_SUMMARY,
2692
  EMPTY_TRACE,
2693
  None,
2694
  render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
 
2718
  f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
2719
  EMPTY_AGENT_STATE,
2720
  EMPTY_DIRECTOR_LOG,
2721
+ EMPTY_TRACE_SUMMARY,
2722
  EMPTY_TRACE,
2723
  None,
2724
  render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
 
2788
  f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
2789
  EMPTY_AGENT_STATE,
2790
  EMPTY_DIRECTOR_LOG,
2791
+ EMPTY_TRACE_SUMMARY,
2792
  EMPTY_TRACE,
2793
  None,
2794
  render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
 
2818
  f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
2819
  EMPTY_AGENT_STATE,
2820
  EMPTY_DIRECTOR_LOG,
2821
+ EMPTY_TRACE_SUMMARY,
2822
  EMPTY_TRACE,
2823
  None,
2824
  render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
 
2847
  f'<div class="voice-status">{escape(EMPTY_TTS_STATUS)}</div>',
2848
  EMPTY_AGENT_STATE,
2849
  EMPTY_DIRECTOR_LOG,
2850
+ EMPTY_TRACE_SUMMARY,
2851
  EMPTY_TRACE,
2852
  None,
2853
  render_backend_settings(None, backend_name, director_mode, max_new_tokens, temperature),
 
2899
  return (
2900
  session,
2901
  render_director_log(session),
2902
+ render_trace_summary_text(session),
2903
  render_trace(session),
2904
  write_trace_json_file(session),
2905
  render_backend_settings(
 
3026
  elem_classes=["no-field-label"],
3027
  )
3028
  with gr.Accordion("Behind the Curtain", open=False):
3029
+ trace_summary_output = gr.Textbox(
3030
+ value=EMPTY_TRACE_SUMMARY,
3031
+ label="Trace Summary",
3032
+ lines=8,
3033
+ interactive=False,
3034
+ buttons=["copy"],
3035
+ elem_classes=["no-field-label"],
3036
+ )
3037
  director_output = gr.Textbox(
3038
  value=EMPTY_DIRECTOR_LOG,
3039
  label="Director Log",
 
3119
  tts_status_output,
3120
  agent_state_output,
3121
  director_output,
3122
+ trace_summary_output,
3123
  trace_output,
3124
  trace_download,
3125
  backend_output,
 
3144
  tts_status_output,
3145
  agent_state_output,
3146
  director_output,
3147
+ trace_summary_output,
3148
  trace_output,
3149
  trace_download,
3150
  backend_output,
 
3170
  tts_status_output,
3171
  agent_state_output,
3172
  director_output,
3173
+ trace_summary_output,
3174
  trace_output,
3175
  trace_download,
3176
  backend_output,
 
3196
  tts_status_output,
3197
  agent_state_output,
3198
  director_output,
3199
+ trace_summary_output,
3200
  trace_output,
3201
  trace_download,
3202
  backend_output,
 
3222
  tts_status_output,
3223
  agent_state_output,
3224
  director_output,
3225
+ trace_summary_output,
3226
  trace_output,
3227
  trace_download,
3228
  backend_output,
 
3247
  tts_status_output,
3248
  agent_state_output,
3249
  director_output,
3250
+ trace_summary_output,
3251
  trace_output,
3252
  trace_download,
3253
  backend_output,
 
3256
  warm_up_button.click(
3257
  warm_up_backend,
3258
  inputs=[session_state, max_new_tokens_input, temperature_input],
3259
+ outputs=[session_state, director_output, trace_summary_output, trace_output, trace_download, backend_output],
3260
  )
3261
  reset_button.click(
3262
  reset_show,
 
3272
  tts_status_output,
3273
  agent_state_output,
3274
  director_output,
3275
+ trace_summary_output,
3276
  trace_output,
3277
  trace_download,
3278
  show_length_select,
puppet_theater/director.py CHANGED
@@ -453,6 +453,18 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
453
  fallback_used=director_generation.fallback_used,
454
  fallback_reason=director_generation.error,
455
  )
 
 
 
 
 
 
 
 
 
 
 
 
456
 
457
  backend_generation = generate_actor_response(session, decision, speaker, prop)
458
  session.beat_index += 1
@@ -557,6 +569,19 @@ def run_one_beat(session: TheaterSession | None) -> TheaterSession | None:
557
  fallback_used=backend_generation.fallback_used,
558
  fallback_reason=backend_generation.error,
559
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
560
 
561
  if decision.should_end_scene or decision.beat_type == "finale":
562
  session.beat_index = session.max_beats
 
453
  fallback_used=director_generation.fallback_used,
454
  fallback_reason=director_generation.error,
455
  )
456
+ if director_generation.fallback_used:
457
+ add_trace_event(
458
+ session,
459
+ "director_fallback",
460
+ speaker=speaker.name,
461
+ backend_name=director_generation.director_mode,
462
+ model_id=director_generation.model_id,
463
+ validation_status=director_generation.validation_status,
464
+ fallback_used=True,
465
+ fallback_reason=director_generation.error or director_generation.validation_status,
466
+ story_phase=current_phase,
467
+ )
468
 
469
  backend_generation = generate_actor_response(session, decision, speaker, prop)
470
  session.beat_index += 1
 
569
  fallback_used=backend_generation.fallback_used,
570
  fallback_reason=backend_generation.error,
571
  )
572
+ if backend_generation.fallback_used:
573
+ add_trace_event(
574
+ session,
575
+ "actor_fallback",
576
+ beat_index=session.beat_index,
577
+ speaker=speaker.name,
578
+ backend_name=backend_generation.backend_name,
579
+ model_id=backend_generation.model_id,
580
+ validation_status=backend_generation.validation_status,
581
+ fallback_used=True,
582
+ fallback_reason=backend_generation.error or backend_generation.validation_status,
583
+ load_status=backend_generation.load_status,
584
+ )
585
 
586
  if decision.should_end_scene or decision.beat_type == "finale":
587
  session.beat_index = session.max_beats
puppet_theater/models.py CHANGED
@@ -161,6 +161,7 @@ class TheaterSession:
161
  min_beats: int = 7
162
  target_beats: int = 10
163
  max_beats: int = 12
 
164
  transcript: list[Beat] = field(default_factory=list)
165
  props: list[str] = field(default_factory=list)
166
  latest_prop: str | None = None
 
161
  min_beats: int = 7
162
  target_beats: int = 10
163
  max_beats: int = 12
164
+ show_length_mode: str = "standard"
165
  transcript: list[Beat] = field(default_factory=list)
166
  props: list[str] = field(default_factory=list)
167
  latest_prop: str | None = None
puppet_theater/session.py CHANGED
@@ -106,6 +106,7 @@ def create_show_from_premise(
106
  min_beats=min_beats,
107
  target_beats=target_beats,
108
  max_beats=max_beats,
 
109
  transcript=[],
110
  props=[],
111
  latest_prop=None,
 
106
  min_beats=min_beats,
107
  target_beats=target_beats,
108
  max_beats=max_beats,
109
+ show_length_mode=active_show_length,
110
  transcript=[],
111
  props=[],
112
  latest_prop=None,
puppet_theater/trace.py CHANGED
@@ -13,6 +13,26 @@ from puppet_theater.models import TheaterSession
13
  APP_NAME = "AI Puppet Theater"
14
  TRACE_VERSION = "1.0"
15
  _MAX_TEXT_CHARS = 500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  _PRIVATE_PATH_PATTERNS = [
17
  re.compile(r"/Users/[^\s\"'<>:]+(?:/[^\s\"'<>:]+)*"),
18
  re.compile(r"/private/(?:tmp|var)/[^\s\"'<>:]+(?:/[^\s\"'<>:]+)*"),
@@ -22,7 +42,7 @@ _PRIVATE_PATH_PATTERNS = [
22
 
23
  def add_trace_event(session: TheaterSession, event_type: str, **fields: Any) -> None:
24
  event: dict[str, Any] = {
25
- "type": sanitize_value(event_type),
26
  "timestamp": datetime.now(timezone.utc).isoformat(),
27
  "step": len(session.trace_events) + 1,
28
  }
@@ -49,6 +69,7 @@ def export_trace(session: TheaterSession | None) -> dict[str, Any] | None:
49
  "premise": sanitize_value(session.premise),
50
  "title": sanitize_value(session.show_title),
51
  "setting": sanitize_value(session.setting),
 
52
  "min_beats": session.min_beats,
53
  "target_beats": session.target_beats,
54
  "max_beats": session.max_beats,
@@ -88,22 +109,193 @@ def normalize_trace_events(session: TheaterSession) -> list[dict[str, Any]]:
88
  normalized: list[dict[str, Any]] = []
89
  for index, raw_event in enumerate(session.trace_events, start=1):
90
  if isinstance(raw_event, dict):
91
- event = {str(key): sanitize_value(value) for key, value in raw_event.items() if value is not None}
92
- event.setdefault("type", "event")
93
- event.setdefault("step", index)
94
- normalized.append(event)
95
  continue
96
 
97
  normalized.append(
98
  {
99
- "type": "legacy_event",
100
  "step": index,
101
  "message": sanitize_value(raw_event),
 
102
  }
103
  )
104
  return normalized
105
 
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  def sanitize_value(value: Any) -> Any:
108
  if isinstance(value, dict):
109
  return {
@@ -123,20 +315,41 @@ def sanitize_value(value: Any) -> Any:
123
  def _sanitize_text(value: str) -> str:
124
  text = " ".join(value.split())
125
  if "Traceback (most recent call last)" in text:
126
- text = "Traceback redacted"
 
 
 
 
127
  for secret_name in ("HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN"):
128
  secret = os.getenv(secret_name)
129
  if secret:
130
  text = text.replace(secret, "[redacted]")
131
- text = re.sub(r"\b(HF_TOKEN|HUGGINGFACEHUB_API_TOKEN)\s*=\s*\S+", r"\1=[redacted]", text)
 
 
 
 
132
  for pattern in _PRIVATE_PATH_PATTERNS:
133
  text = pattern.sub("[redacted-path]", text)
134
  return text[:_MAX_TEXT_CHARS].rstrip()
135
 
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  def _is_sensitive_key(key: str) -> bool:
138
  lowered = key.lower()
139
- return "token" in lowered or "secret" in lowered or "password" in lowered
140
 
141
 
142
  def _model_id_for_mode(mode: str | None) -> str | None:
 
13
  APP_NAME = "AI Puppet Theater"
14
  TRACE_VERSION = "1.0"
15
  _MAX_TEXT_CHARS = 500
16
+ _STANDARD_EVENT_KEYS = (
17
+ "event_type",
18
+ "timestamp",
19
+ "step",
20
+ "beat_index",
21
+ "story_phase",
22
+ "speaker",
23
+ "director_decision",
24
+ "actor_intent",
25
+ "actor_memory_update",
26
+ "tool_request",
27
+ "tool_result",
28
+ "audience_action",
29
+ "backend_metadata",
30
+ "latency_ms",
31
+ "validation_status",
32
+ "fallback_used",
33
+ "fallback_reason",
34
+ "summary",
35
+ )
36
  _PRIVATE_PATH_PATTERNS = [
37
  re.compile(r"/Users/[^\s\"'<>:]+(?:/[^\s\"'<>:]+)*"),
38
  re.compile(r"/private/(?:tmp|var)/[^\s\"'<>:]+(?:/[^\s\"'<>:]+)*"),
 
42
 
43
  def add_trace_event(session: TheaterSession, event_type: str, **fields: Any) -> None:
44
  event: dict[str, Any] = {
45
+ "event_type": sanitize_value(event_type),
46
  "timestamp": datetime.now(timezone.utc).isoformat(),
47
  "step": len(session.trace_events) + 1,
48
  }
 
69
  "premise": sanitize_value(session.premise),
70
  "title": sanitize_value(session.show_title),
71
  "setting": sanitize_value(session.setting),
72
+ "show_length_mode": sanitize_value(session.show_length_mode),
73
  "min_beats": session.min_beats,
74
  "target_beats": session.target_beats,
75
  "max_beats": session.max_beats,
 
109
  normalized: list[dict[str, Any]] = []
110
  for index, raw_event in enumerate(session.trace_events, start=1):
111
  if isinstance(raw_event, dict):
112
+ normalized.append(_normalize_event_dict(raw_event, index))
 
 
 
113
  continue
114
 
115
  normalized.append(
116
  {
117
+ "event_type": "legacy_event",
118
  "step": index,
119
  "message": sanitize_value(raw_event),
120
+ "summary": sanitize_value(raw_event),
121
  }
122
  )
123
  return normalized
124
 
125
 
126
+ def render_trace_summary(session: TheaterSession | None, max_events: int = 14) -> str:
127
+ if session is None:
128
+ return "No trace events yet."
129
+
130
+ events = normalize_trace_events(session)
131
+ if not events:
132
+ return "No trace events yet."
133
+
134
+ lines = [
135
+ f"{session.show_title} ({session.session_id})",
136
+ (
137
+ f"{session.show_length_mode} show: {session.min_beats}/"
138
+ f"{session.target_beats}/{session.max_beats} beats, "
139
+ f"actor backend={session.backend_name}, director={session.director_mode}"
140
+ ),
141
+ "",
142
+ ]
143
+ for event in events[-max_events:]:
144
+ step = event.get("step", "?")
145
+ event_type = event.get("event_type", "event")
146
+ beat = event.get("beat_index")
147
+ prefix = f"{step}. {event_type}"
148
+ if beat is not None:
149
+ prefix += f" [beat {beat}]"
150
+ lines.append(f"{prefix}: {_event_summary(event)}")
151
+ return "\n".join(lines)
152
+
153
+
154
+ def _normalize_event_dict(raw_event: dict[str, Any], index: int) -> dict[str, Any]:
155
+ cleaned = {str(key): sanitize_value(value) for key, value in raw_event.items() if value is not None}
156
+ event_type = str(cleaned.pop("event_type", cleaned.pop("type", "event")) or "event")
157
+ normalized: dict[str, Any] = {
158
+ "event_type": sanitize_value(event_type),
159
+ "step": cleaned.pop("step", index),
160
+ }
161
+ if "timestamp" in cleaned:
162
+ normalized["timestamp"] = cleaned.pop("timestamp")
163
+
164
+ for key in (
165
+ "beat_index",
166
+ "story_phase",
167
+ "speaker",
168
+ "latency_ms",
169
+ "validation_status",
170
+ "fallback_used",
171
+ "fallback_reason",
172
+ ):
173
+ if key in cleaned:
174
+ normalized[key] = cleaned.pop(key)
175
+
176
+ if event_type == "director_decision":
177
+ normalized["director_decision"] = {
178
+ key: cleaned.pop(key)
179
+ for key in (
180
+ "beat_type",
181
+ "reason_summary",
182
+ "progress",
183
+ "uses_prop",
184
+ "reveal_secret",
185
+ "should_end_scene",
186
+ "min_beats",
187
+ "target_beats",
188
+ "max_beats",
189
+ )
190
+ if key in cleaned
191
+ }
192
+ if event_type == "actor_intent" and "intent" in cleaned:
193
+ normalized["actor_intent"] = cleaned.pop("intent")
194
+ if event_type == "actor_memory_update" and "memory_update" in cleaned:
195
+ normalized["actor_memory_update"] = cleaned.pop("memory_update")
196
+ if event_type in {"tool_requested", "tool_executed", "tool_ignored"}:
197
+ normalized["tool_request"] = {
198
+ key: cleaned.pop(key)
199
+ for key in ("tool_name", "reason", "arguments")
200
+ if key in cleaned
201
+ }
202
+ if event_type == "tool_result":
203
+ normalized["tool_result"] = {
204
+ key: cleaned.pop(key)
205
+ for key in ("tool_name", "result", "stage_effect")
206
+ if key in cleaned
207
+ }
208
+ if event_type == "audience_action":
209
+ action = cleaned.pop("audience_action", None)
210
+ normalized["audience_action"] = {
211
+ key: value
212
+ for key, value in {
213
+ "action": action,
214
+ "summary": cleaned.pop("action_summary", None),
215
+ "prop": cleaned.pop("prop", None),
216
+ "summoned_actor": cleaned.pop("summoned_actor", None),
217
+ }.items()
218
+ if value is not None
219
+ }
220
+
221
+ backend_metadata = {
222
+ key: cleaned.pop(key)
223
+ for key in (
224
+ "backend_name",
225
+ "model_id",
226
+ "load_status",
227
+ "director_mode",
228
+ "tts_backend",
229
+ "voice_mode",
230
+ "voice_name",
231
+ )
232
+ if key in cleaned
233
+ }
234
+ if backend_metadata:
235
+ normalized["backend_metadata"] = backend_metadata
236
+
237
+ if "reason_summary" in cleaned:
238
+ normalized["summary"] = cleaned.pop("reason_summary")
239
+ elif "action_summary" in cleaned:
240
+ normalized["summary"] = cleaned.pop("action_summary")
241
+ elif "error_summary" in cleaned:
242
+ normalized["summary"] = cleaned.pop("error_summary")
243
+
244
+ for key, value in cleaned.items():
245
+ if key not in normalized:
246
+ normalized[key] = value
247
+
248
+ if "summary" not in normalized:
249
+ normalized["summary"] = _event_summary(normalized)
250
+ return {key: normalized[key] for key in _STANDARD_EVENT_KEYS if key in normalized} | {
251
+ key: value for key, value in normalized.items() if key not in _STANDARD_EVENT_KEYS
252
+ }
253
+
254
+
255
+ def _event_summary(event: dict[str, Any]) -> str:
256
+ event_type = str(event.get("event_type") or "event")
257
+ if event_type == "show_created":
258
+ backend = _backend_label(event)
259
+ return f"Show created with {backend}."
260
+ if event_type == "actors_created":
261
+ return f"{event.get('actor_count', 'Actors')} actors created."
262
+ if event_type == "director_decision":
263
+ decision = event.get("director_decision") if isinstance(event.get("director_decision"), dict) else {}
264
+ beat_type = decision.get("beat_type") if isinstance(decision, dict) else None
265
+ speaker = event.get("speaker") or "next actor"
266
+ reason = decision.get("reason_summary") if isinstance(decision, dict) else None
267
+ return f"Director chose {speaker} for {beat_type or 'a beat'}" + (f": {reason}" if reason else ".")
268
+ if event_type in {"actor_response", "backend_error", "backend_warmup"}:
269
+ status = event.get("validation_status") or "status unknown"
270
+ fallback = " with fallback" if event.get("fallback_used") else ""
271
+ return f"Backend returned {status}{fallback}."
272
+ if event_type in {"actor_fallback", "director_fallback"}:
273
+ reason = event.get("fallback_reason") or event.get("validation_status") or "fallback used"
274
+ return f"Fallback used: {reason}."
275
+ if event_type == "actor_intent":
276
+ return str(event.get("actor_intent") or "Actor intent recorded.")
277
+ if event_type == "actor_memory_update":
278
+ return str(event.get("actor_memory_update") or "Actor memory updated.")
279
+ if event_type == "tool_requested":
280
+ tool = _tool_label(event.get("tool_request"))
281
+ return f"Tool requested: {tool}."
282
+ if event_type == "tool_executed":
283
+ tool = _tool_label(event.get("tool_request"))
284
+ return f"Tool executed: {tool}."
285
+ if event_type == "tool_result":
286
+ result = event.get("tool_result") if isinstance(event.get("tool_result"), dict) else {}
287
+ return str(result.get("result") or f"Tool result from {result.get('tool_name', 'tool')}.")
288
+ if event_type == "audience_action":
289
+ action = event.get("audience_action") if isinstance(event.get("audience_action"), dict) else {}
290
+ return str(action.get("summary") or action.get("action") or "Audience action recorded.")
291
+ if event_type in {"tts_requested", "tts_generated", "tts_fallback"}:
292
+ metadata = event.get("backend_metadata") if isinstance(event.get("backend_metadata"), dict) else {}
293
+ return f"TTS event via {metadata.get('tts_backend', 'voice backend')}."
294
+ if event_type == "scene_completed":
295
+ return str(event.get("summary") or "Scene completed.")
296
+ return str(event.get("summary") or event_type.replace("_", " ").title())
297
+
298
+
299
  def sanitize_value(value: Any) -> Any:
300
  if isinstance(value, dict):
301
  return {
 
315
  def _sanitize_text(value: str) -> str:
316
  text = " ".join(value.split())
317
  if "Traceback (most recent call last)" in text:
318
+ text = "Error details redacted"
319
+ for key, secret in os.environ.items():
320
+ if not _is_sensitive_key(key) or not secret:
321
+ continue
322
+ text = text.replace(secret, "[redacted]")
323
  for secret_name in ("HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN"):
324
  secret = os.getenv(secret_name)
325
  if secret:
326
  text = text.replace(secret, "[redacted]")
327
+ text = re.sub(
328
+ r"\b([A-Z][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|CREDENTIAL|API_KEY|ACCESS_KEY)[A-Z0-9_]*)\s*=\s*\S+",
329
+ r"\1=[redacted]",
330
+ text,
331
+ )
332
  for pattern in _PRIVATE_PATH_PATTERNS:
333
  text = pattern.sub("[redacted-path]", text)
334
  return text[:_MAX_TEXT_CHARS].rstrip()
335
 
336
 
337
+ def _backend_label(event: dict[str, Any]) -> str:
338
+ metadata = event.get("backend_metadata") if isinstance(event.get("backend_metadata"), dict) else {}
339
+ backend = metadata.get("backend_name") or metadata.get("director_mode") or event.get("backend_name") or "backend"
340
+ model = metadata.get("model_id") or event.get("model_id")
341
+ return f"{backend} ({model})" if model else str(backend)
342
+
343
+
344
+ def _tool_label(tool_request: Any) -> str:
345
+ if isinstance(tool_request, dict):
346
+ return str(tool_request.get("tool_name") or "tool")
347
+ return "tool"
348
+
349
+
350
  def _is_sensitive_key(key: str) -> bool:
351
  lowered = key.lower()
352
+ return any(marker in lowered for marker in ("token", "secret", "password", "credential", "api_key", "access_key"))
353
 
354
 
355
  def _model_id_for_mode(mode: str | None) -> str | None: