fluxloop-cli 0.2.26__py3-none-any.whl → 0.2.28__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fluxloop_cli/__init__.py CHANGED
@@ -2,7 +2,7 @@
2
2
  FluxLoop CLI - Command-line interface for running agent simulations.
3
3
  """
4
4
 
5
- __version__ = "0.2.26"
5
+ __version__ = "0.2.28"
6
6
 
7
7
  from .main import app
8
8
 
@@ -75,6 +75,9 @@ class TraceSummary:
75
75
  duration_ms: float
76
76
  success: bool
77
77
  raw: dict
78
+ conversation: Optional[List[Dict[str, Any]]] = None
79
+ conversation_state: Optional[Dict[str, Any]] = None
80
+ termination_reason: Optional[str] = None
78
81
 
79
82
  def to_payload(self) -> dict:
80
83
  """Return a JSON-serialisable representation of the summary entry."""
@@ -88,6 +91,9 @@ class TraceSummary:
88
91
  "duration_ms": self.duration_ms,
89
92
  "success": self.success,
90
93
  "raw": self.raw,
94
+ "conversation": self.conversation,
95
+ "conversation_state": self.conversation_state,
96
+ "termination_reason": self.termination_reason,
91
97
  }
92
98
 
93
99
 
@@ -164,6 +170,9 @@ def _load_trace_summaries(path: Path) -> Iterable[TraceSummary]:
164
170
  output_text=payload.get("output"),
165
171
  duration_ms=payload.get("duration_ms", 0.0),
166
172
  success=payload.get("success", False),
173
+ conversation=payload.get("conversation"),
174
+ conversation_state=payload.get("conversation_state"),
175
+ termination_reason=payload.get("termination_reason"),
167
176
  raw=payload,
168
177
  )
169
178
 
@@ -214,6 +223,30 @@ def _format_markdown(
214
223
  f"{_format_json_block({'output': trace.output_text})}\n\n"
215
224
  )
216
225
 
226
+ conversation_section = ""
227
+ if trace.conversation:
228
+ conversation_lines = ["## Conversation\n"]
229
+ for entry in trace.conversation:
230
+ role = (entry.get("role") or "unknown").capitalize()
231
+ content = entry.get("content") or ""
232
+ source = entry.get("source")
233
+ metadata = entry.get("metadata") or {}
234
+ meta_bits: List[str] = []
235
+ if source:
236
+ meta_bits.append(f"source={source}")
237
+ actions = metadata.get("actions") or []
238
+ if actions:
239
+ meta_bits.append("actions=" + ", ".join(actions))
240
+ if metadata.get("closing"):
241
+ meta_bits.append("closing=true")
242
+ persona = metadata.get("persona")
243
+ if persona:
244
+ meta_bits.append(f"persona={persona}")
245
+ meta_suffix = f" _({' ; '.join(meta_bits)})_" if meta_bits else ""
246
+ conversation_lines.append(f"- **{role}**: {content}{meta_suffix}")
247
+ conversation_lines.append("")
248
+ conversation_section = "\n".join(conversation_lines)
249
+
217
250
  timeline_lines = ["## Timeline\n"]
218
251
 
219
252
  for index, obs in enumerate(observations_sorted, start=1):
@@ -238,7 +271,7 @@ def _format_markdown(
238
271
  if not observations_sorted:
239
272
  timeline_lines.append("(no observations recorded)\n")
240
273
 
241
- return header + summary_section + "".join(timeline_lines)
274
+ return header + summary_section + conversation_section + "".join(timeline_lines)
242
275
 
243
276
 
244
277
  def _sort_observations(
@@ -278,12 +311,40 @@ def _build_structured_record(
278
311
  "success": summary_payload["success"],
279
312
  "summary": summary_payload,
280
313
  "timeline": timeline,
314
+ "conversation": trace.conversation or summary_payload.get("conversation") or [],
281
315
  }
282
316
 
317
+ def _maybe_decode_content(value: Any) -> Any:
318
+ if isinstance(value, str):
319
+ text = value.strip()
320
+ if text and text[0] in "{[" and text[-1] in "}]" and len(text) >= 2:
321
+ try:
322
+ return json.loads(text)
323
+ except (json.JSONDecodeError, TypeError):
324
+ return value
325
+ return value
326
+
283
327
  record["metrics"] = {
284
328
  "observation_count": len(timeline),
285
329
  }
286
330
 
331
+ if trace.conversation_state:
332
+ record["conversation_state"] = trace.conversation_state
333
+ if trace.termination_reason:
334
+ record["termination_reason"] = trace.termination_reason
335
+ if not record["conversation"] and trace.raw.get("conversation"):
336
+ record["conversation"] = trace.raw.get("conversation")
337
+ if record["conversation"]:
338
+ normalized_conversation: List[Dict[str, Any]] = []
339
+ for entry in record["conversation"]:
340
+ if not isinstance(entry, dict):
341
+ normalized_conversation.append(entry)
342
+ continue
343
+ normalized_entry = dict(entry)
344
+ normalized_entry["content"] = _maybe_decode_content(entry.get("content"))
345
+ normalized_conversation.append(normalized_entry)
346
+ record["conversation"] = normalized_conversation
347
+
287
348
  return record
288
349
 
289
350
 
@@ -3,6 +3,7 @@ Run command for executing experiments and simulations.
3
3
  """
4
4
 
5
5
  import asyncio
6
+ import json
6
7
  import sys
7
8
  from pathlib import Path
8
9
  from typing import Optional
@@ -27,6 +28,16 @@ app = typer.Typer()
27
28
  console = Console()
28
29
 
29
30
 
31
+ def _emit_progress_event(event: str, **payload: object) -> None:
32
+ """Emit a structured progress event to stderr for machine consumption."""
33
+ data = {"event": event, **payload}
34
+ try:
35
+ print(json.dumps(data, default=str), file=sys.stderr, flush=True)
36
+ except Exception:
37
+ # Fall back to repr if serialization fails
38
+ print({"event": event, **{k: repr(v) for k, v in payload.items()}}, file=sys.stderr, flush=True)
39
+
40
+
30
41
  @app.command()
31
42
  def experiment(
32
43
  config_file: Path = typer.Option(
@@ -227,9 +238,20 @@ def experiment(
227
238
  summary.add_row("Output", config.output_directory)
228
239
 
229
240
  console.print(summary)
241
+
242
+ _emit_progress_event(
243
+ "experiment_summary",
244
+ name=config.name,
245
+ iterations=config.iterations,
246
+ personas=len(config.personas),
247
+ total_runs=total_runs,
248
+ multi_turn_enabled=bool(config.multi_turn and config.multi_turn.enabled),
249
+ output_dir=config.output_directory,
250
+ )
230
251
 
231
252
  if dry_run:
232
253
  console.print("\n[yellow]Dry run mode - no execution will occur[/yellow]")
254
+ _emit_progress_event("experiment_status", status="dry_run", name=config.name)
233
255
  return
234
256
 
235
257
  # Confirm execution
@@ -243,13 +265,22 @@ def experiment(
243
265
  f"\nThis will execute {total_runs} runs."
244
266
  )
245
267
 
268
+ _emit_progress_event(
269
+ "experiment_ready",
270
+ name=config.name,
271
+ total_runs=total_runs,
272
+ )
273
+
246
274
  if not typer.confirm("Continue?"):
275
+ _emit_progress_event("experiment_status", status="aborted", reason="user_declined", name=config.name)
247
276
  raise typer.Abort()
248
277
 
249
278
  # Create runner
250
279
  # Run experiment with progress tracking
251
280
  console.print("\n[bold green]▶️ Starting experiment...[/bold green]\n")
252
281
 
282
+ _emit_progress_event("experiment_started", name=config.name, total_runs=total_runs)
283
+
253
284
  with Progress(
254
285
  SpinnerColumn(),
255
286
  TextColumn("[progress.description]{task.description}"),
@@ -277,6 +308,14 @@ def experiment(
277
308
 
278
309
  def _progress_callback():
279
310
  progress.advance(main_task)
311
+ task = progress.tasks[main_task]
312
+ _emit_progress_event(
313
+ "run_progress",
314
+ completed=int(task.completed),
315
+ total=int(task.total or 0),
316
+ description=task.description,
317
+ experiment=config.name,
318
+ )
280
319
 
281
320
  def _turn_progress_callback(
282
321
  current_turn: int,
@@ -295,6 +334,13 @@ def experiment(
295
334
  description=description,
296
335
  visible=False,
297
336
  )
337
+ _emit_progress_event(
338
+ "turn_progress",
339
+ state="complete",
340
+ current=min(clamped_turn, total),
341
+ total=total,
342
+ experiment=config.name,
343
+ )
298
344
  return
299
345
 
300
346
  if not progress.tasks[turn_task].visible:
@@ -313,6 +359,14 @@ def experiment(
313
359
  completed=completed,
314
360
  description=description,
315
361
  )
362
+ _emit_progress_event(
363
+ "turn_progress",
364
+ state="in_progress",
365
+ current=clamped_turn,
366
+ total=total,
367
+ preview=preview,
368
+ experiment=config.name,
369
+ )
316
370
 
317
371
  # Run experiment
318
372
  try:
@@ -324,12 +378,14 @@ def experiment(
324
378
  )
325
379
  except KeyboardInterrupt:
326
380
  console.print("\n[yellow]Experiment interrupted by user[/yellow]")
381
+ _emit_progress_event("experiment_status", status="interrupted", name=config.name)
327
382
  raise typer.Exit(1)
328
383
  except Exception as e:
329
384
  console.print(f"\n[red]Error during experiment:[/red] {e}")
330
385
  # Debug mode - show full traceback
331
386
  if "--debug" in sys.argv:
332
387
  console.print_exception()
388
+ _emit_progress_event("experiment_status", status="error", error=str(e), name=config.name)
333
389
  raise typer.Exit(1)
334
390
 
335
391
  config.set_resolved_input_count(results.get("input_count", config.get_input_count()))
@@ -343,6 +399,15 @@ def experiment(
343
399
  )
344
400
 
345
401
  _display_results(results)
402
+ _emit_progress_event(
403
+ "experiment_status",
404
+ status="completed",
405
+ name=config.name,
406
+ total_runs=results.get("total_runs", total_runs),
407
+ successful=results.get("successful"),
408
+ failed=results.get("failed"),
409
+ success_rate=results.get("success_rate"),
410
+ )
346
411
 
347
412
 
348
413
  @app.command()
@@ -217,6 +217,12 @@ def _write_per_trace(results: List[TraceOutcome], path: Path) -> None:
217
217
  if outcome.trace.get(key) is not None
218
218
  },
219
219
  }
220
+ if outcome.trace.get("conversation") is not None:
221
+ payload["conversation"] = outcome.trace.get("conversation")
222
+ if outcome.trace.get("conversation_state") is not None:
223
+ payload["conversation_state"] = outcome.trace.get("conversation_state")
224
+ if outcome.trace.get("termination_reason") is not None:
225
+ payload["termination_reason"] = outcome.trace.get("termination_reason")
220
226
  handle.write(json.dumps(payload, ensure_ascii=False) + "\n")
221
227
 
222
228
 
@@ -14,6 +14,39 @@ if TYPE_CHECKING:
14
14
  from ...config import EvaluationConfig
15
15
 
16
16
 
17
+ FLUXLOOP_LOGO_DATA_URI = (
18
+ "data:image/png;base64,"
19
+ "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAIRlWElmTU0AKgAAAAgABQESAAMAAAABAAEAAAEaAAUA"
20
+ "AAABAAAASgEbAAUAAAABAAAAUgEoAAMAAAABAAIAAIdpAAQAAAABAAAAWgAAAAAAAABIAAAAAQAAAEgAAAABAAOgAQADAAAAAQABAACgAgAE"
21
+ "AAAAAQAAABigAwAEAAAAAQAAABgAAAAAEQ8YrgAAAAlwSFlzAAALEwAACxMBAJqcGAAAAVlpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4"
22
+ "OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDYuMC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRm"
23
+ "PSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9"
24
+ "IiIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRh"
25
+ "dGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KGV7h"
26
+ "BwAABlJJREFUSA2VVQtwlFcVPvf+99/9N9lHsnlsaCAh4VEmkKAmhqZMcKmTzuAA6jhbYXA6RpxAHWxt61jHQbtapY8o1VHbxk6xltJaGK1a"
27
+ "rDC1Ztu0IzAkpSVmAIEAee5uNg92N/v4/3uP9266gdpSxzOze+8959zvO/ec+58L8L8k2M0g8C8bBJHOuwZQWyl1ATnO6/7vyUG5+XrQGwAE"
28
+ "Agc19buBGchHGrpQhx3EVLaF7efWmIJswYxoBhOdxORx3cSMkRWnjKz5u/6exveUX2PjSb23tym3R63z8mGCIDIIEqvyvuHlPGu9iOD+FM7E"
29
+ "YpDlo2hBnYaGxpLxw4aJixxQuJpl4iGXSb725onVg35/NwuF1lt5cDVey6tavQ/u+8GVL1su91kOJAkzk/WR/ctKIy+taPCaE8WEpw4QRhs0"
30
+ "K/VFkp6sJwg1nBkX/c0nv6DAFYmCysu1E7yfFt8jI5sQHX+BmcRDbiPz09kULaLZcHT48VtT+U1LNvRf1rO84Mzrq8uUbm1L35tOWtYK6dGN"
31
+ "R3vX/PX6dM2dQBVT5rzm6bAPbfQlxFQUHcR/lXtmuF5xOZOtmPVtP//Q0qWv2hWggfy7Tkdl6afX9W5Ta4fGvyrMGOio/WnLqjcWqVrkC59P"
32
+ "UW5MIvwQKiocqNExpOQ1IszNIhNrlxhv0ZIluxMttfsVoKBkhIoUMMQqtaYFtrCG5tsGaEyjtkeULhoty2VHFlRGL4ta80LYFzfZDpgOv119"
33
+ "YWR9728+cCOerWg/u0tzLf5l+bb+tUYkS5jDAZSSmAKDdMZLCCmjIv2YAfSeu+p7vv9kqPViEJAyqBtQRcnOIm4EpwtIZnqXAvd1jpdzindi"
34
+ "0myjs9wiafMZnBgcsAvawZmGAtOgJZKvKHxqadsZxSWMiCcMQnbqoG2W6p/DygFGYaoOlZNgpFWkpsaiHeWnyg5ElpkeOE0qKjophWaOxGdx"
35
+ "8rywQ5Uu+CbQyBYrE9va07tu7LbWk18v1MseZIK//MI7ay8XAo47EZsU5qiRkukZC+UIZLSLkOIZZcgAhoSdldPYlc3R3VXFVZVjv3IXJTTd"
36
+ "zp023V6sExx2CNF427q+syWs6mmeGj/nAtip9roFT7tBlKj5Ta64JPiMX80BCRKi4WTx78PbiMd7U5FIfzJ8d/Urm549em+p2/bbYiP5TqUt"
37
+ "gR6YiVHOB3UhvlXIuQeTg4+tSEVuee7EmlhjY5fuEbDExTGRA5V/DKIDuRtECP5bUjRxEF9xJCIHLrUvPHXXHw7XMmLtnb46+FS1aYukHLoT"
38
+ "U66FDoF/2/eP1rY8SH7clVxxv9dWUTiVHT6tdHXRqCxy7VwNGIOjWQHtlONECbHuCUuHpTDU5pKFH8Whzkr0PgA2K35yov7RFcU1P3u4uafJ"
39
+ "za0nspSNOlMZpxO1ewWKQNoKgwHZY4pgyqiVKWqEXO8ocGdepZoIa273giL11UippeHK5bMDEKweHGomfe9+zjh3S7Bl3YsTF4bvX+xwby1n"
40
+ "pT0LOL2w2OF7l1C+uoBYR2xkdozWT7+u9u/obbIoyNxAN7JL62vSRON7tNJCsDSRuwUL+ESsjp6H4TPnfA2xrudL+T/BIC1//Mnplr1b32jw"
41
+ "oDX0DR3ie4YmJz+7wLjy+SJj9ksFLNV5x6E7eJesh+TIXSA54HxPKvnzxWMNRy4pUic+B4X8cBsm9q38jorI/HHlffiL5Zh64BNn8XuwSumU"
42
+ "4Deh7L22zsnjtz7Tl1sDyKjnMOdaRe4Uc10wpmkb4iI+viEVmiKL+wpg4rVvMyv7I+z0leu7R/bCdOROnUV8yFednr27+cTU9tuP8/iuiN2Y"
43
+ "SHv5kF8RQOAgJYpDSr4XAayXfbxbkmysnhr0GjcbItn3KO+PHHHurJQtuh9tpAf3lKwhD07v18zRRYIOdTB9xF3kTTRPmVf+7iVv3bzsePBq"
44
+ "tz/IiEyRAlcyn5q5JYBfkoQUmZTHX364ozE9EKxL9haX0HEDMhwwZqUxK98Ji9p5xj5spj2dhb8+v0/5owIPBT/w4HyIQDmCfI8xAEI2sNwx"
45
+ "J59cudaTHm+g2Wy1kO8A5TRipbVj7MJMNzkEuWgxENDIoUPzkedwPvZPFr6rq0PdhI8VDPqZjOKjA5U7b2i4HlWBAISu1WvOKEhw7hu63ve/"
46
+ "5/8B6E/qF9KU55kAAAAASUVORK5CYII="
47
+ )
48
+
49
+
17
50
  def serialize_trace_outcome(outcome: "TraceOutcome") -> Dict[str, Any]:
18
51
  return {
19
52
  "trace_id": outcome.trace.get("trace_id"),
@@ -25,6 +58,9 @@ def serialize_trace_outcome(outcome: "TraceOutcome") -> Dict[str, Any]:
25
58
  "final_score": outcome.final_score,
26
59
  "pass": outcome.passed,
27
60
  "reasons": outcome.reasons,
61
+ "conversation": outcome.trace.get("conversation"),
62
+ "conversation_state": outcome.trace.get("conversation_state"),
63
+ "termination_reason": outcome.trace.get("termination_reason"),
28
64
  }
29
65
 
30
66
 
@@ -40,6 +76,7 @@ DEFAULT_TEMPLATE = r"""<!DOCTYPE html>
40
76
  <head>
41
77
  <meta charset="utf-8" />
42
78
  <meta name="viewport" content="width=device-width, initial-scale=1" />
79
+ <meta name="report:generated_at" content="[[DATE]]" />
43
80
  <title>[[TITLE]]</title>
44
81
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css">
45
82
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
@@ -47,14 +84,29 @@ DEFAULT_TEMPLATE = r"""<!DOCTYPE html>
47
84
  <body class="bg-slate-950 text-slate-100">
48
85
  <main class="max-w-6xl mx-auto p-8 space-y-6">
49
86
  <header class="space-y-6">
87
+ <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
88
+ <div class="flex items-center gap-3">
89
+ <img src="[[LOGO_DATA_URI]]" alt="FluxLoop logo" class="h-12 w-12 rounded-full border border-sky-400/40 bg-slate-900/80 p-1.5 shadow-md shadow-sky-900/60" />
50
90
  <div>
51
91
  <h1 class="text-3xl font-bold">[[TITLE]]</h1>
52
- <p class="text-sm text-slate-300">Generated at [[DATE]]</p>
92
+ <p class="text-sm text-slate-300">[[SUBTITLE]]</p>
93
+ </div>
94
+ </div>
95
+ <a
96
+ href="https://fluxloop.io/"
97
+ class="inline-flex items-center gap-2 self-start rounded-full border border-sky-400 px-4 py-2 text-sm font-semibold text-sky-200 transition hover:bg-sky-500/10 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-offset-2 focus:ring-offset-slate-950"
98
+ target="_blank"
99
+ rel="noopener noreferrer"
100
+ >
101
+ <img src="[[LOGO_DATA_URI]]" alt="" aria-hidden="true" class="h-5 w-5 rounded-full border border-sky-400/50 bg-slate-950/70 p-0.5" />
102
+ <span>Built with FluxLoop</span>
103
+ </a>
53
104
  </div>
54
105
  <nav class="flex flex-wrap gap-2" role="tablist" aria-label="Report sections">
55
106
  <button class="tab-button px-4 py-2 rounded-full bg-sky-500/20 border border-sky-400 text-sky-200 font-semibold" data-tab-button="summary">Executive Summary</button>
56
107
  <button class="tab-button px-4 py-2 rounded-full bg-slate-800 border border-slate-700" data-tab-button="personas">Persona Insights</button>
57
108
  <button class="tab-button px-4 py-2 rounded-full bg-slate-800 border border-slate-700" data-tab-button="analysis">Deep Analysis</button>
109
+ <button class="tab-button px-4 py-2 rounded-full bg-slate-800 border border-slate-700" data-tab-button="conversation">Conversation View</button>
58
110
  <button class="tab-button px-4 py-2 rounded-full bg-slate-800 border border-slate-700" data-tab-button="traces">Trace Explorer</button>
59
111
  </nav>
60
112
  </header>
@@ -62,6 +114,11 @@ DEFAULT_TEMPLATE = r"""<!DOCTYPE html>
62
114
  <section data-tab-panel="summary" class="tab-panel space-y-6">
63
115
  <div id="summaryCards" class="grid gap-4 md:grid-cols-2 xl:grid-cols-4"></div>
64
116
 
117
+ <section class="space-y-4">
118
+ <h2 class="text-xl font-semibold">Evaluation Goal</h2>
119
+ <div id="evaluationGoal" class="whitespace-pre-wrap rounded-xl border border-slate-800 bg-slate-900 p-4 text-sm text-slate-300"></div>
120
+ </section>
121
+
65
122
  <section class="space-y-4">
66
123
  <h2 class="text-xl font-semibold">Success Criteria</h2>
67
124
  <div id="criteriaList" class="space-y-3"></div>
@@ -95,6 +152,22 @@ DEFAULT_TEMPLATE = r"""<!DOCTYPE html>
95
152
  <div id="analysisContent" class="space-y-4"></div>
96
153
  </section>
97
154
 
155
+ <section data-tab-panel="conversation" class="tab-panel hidden space-y-4">
156
+ <div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
157
+ <div class="w-full md:w-1/2">
158
+ <label for="conversationTraceSelect" class="block text-sm text-slate-300 font-medium mb-1">Select Trace</label>
159
+ <select id="conversationTraceSelect" class="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-400">
160
+ <option value="">Choose a trace</option>
161
+ </select>
162
+ </div>
163
+ <div class="text-xs text-slate-400">
164
+ Messages are ordered chronologically. Expand each turn to view captured actions.
165
+ </div>
166
+ </div>
167
+ <div id="conversationSummary" class="text-sm text-slate-300"></div>
168
+ <div id="conversationTimeline" class="space-y-3"></div>
169
+ </section>
170
+
98
171
  <section data-tab-panel="traces" class="tab-panel hidden space-y-4">
99
172
  <div class="flex flex-col md:flex-row md:items-end md:justify-between gap-4">
100
173
  <div>
@@ -136,6 +209,7 @@ DEFAULT_TEMPLATE = r"""<!DOCTYPE html>
136
209
  const state = {
137
210
  activeTab: "summary",
138
211
  personaFilter: "all",
212
+ conversationTraceId: (Array.isArray(perTrace) && perTrace.length > 0 && (perTrace[0].trace_id ?? perTrace[0].iteration ?? null)) || null,
139
213
  };
140
214
 
141
215
  function setActiveTab(tab) {
@@ -214,6 +288,240 @@ DEFAULT_TEMPLATE = r"""<!DOCTYPE html>
214
288
  .join("");
215
289
  }
216
290
 
291
+ function initConversationSelect() {
292
+ const select = document.getElementById("conversationTraceSelect");
293
+ if (!select) return;
294
+ const options = Array.isArray(perTrace) ? perTrace : [];
295
+ if (!options.length) {
296
+ select.innerHTML = `<option value="">No traces available</option>`;
297
+ select.disabled = true;
298
+ return;
299
+ }
300
+ select.innerHTML = options
301
+ .map((trace) => {
302
+ const label = trace.trace_id || `Iteration ${trace.iteration ?? ""}` || "Trace";
303
+ const value = trace.trace_id || `iteration-${trace.iteration ?? 0}`;
304
+ return `<option value="${value}">${label}</option>`;
305
+ })
306
+ .join("");
307
+ if (state.conversationTraceId) {
308
+ select.value = state.conversationTraceId;
309
+ } else {
310
+ state.conversationTraceId = options[0].trace_id || `iteration-${options[0].iteration ?? 0}`;
311
+ select.value = state.conversationTraceId;
312
+ }
313
+ select.addEventListener("change", (event) => {
314
+ state.conversationTraceId = event.target.value || null;
315
+ renderConversationTimeline();
316
+ });
317
+ renderConversationTimeline();
318
+ }
319
+
320
+ function escapeHtml(value) {
321
+ return String(value).replace(/[&<>"']/g, (char) => {
322
+ switch (char) {
323
+ case "&":
324
+ return "&amp;";
325
+ case "<":
326
+ return "&lt;";
327
+ case ">":
328
+ return "&gt;";
329
+ case '"':
330
+ return "&quot;";
331
+ case "'":
332
+ return "&#39;";
333
+ default:
334
+ return char;
335
+ }
336
+ });
337
+ }
338
+
339
+ function formatPlainText(value) {
340
+ return escapeHtml(value || "").replace(/\n/g, "<br>");
341
+ }
342
+
343
+ function tryParseJson(text) {
344
+ if (typeof text !== "string") return text;
345
+ const trimmed = text.trim();
346
+ if (
347
+ !trimmed ||
348
+ ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
349
+ (trimmed.startsWith("[") && trimmed.endsWith("]"))) === false
350
+ ) {
351
+ return text;
352
+ }
353
+ try {
354
+ return JSON.parse(trimmed);
355
+ } catch (error) {
356
+ return text;
357
+ }
358
+ }
359
+
360
+ function renderTranscriptObject(obj) {
361
+ const transcript = Array.isArray(obj.transcript) ? obj.transcript : [];
362
+ if (!transcript.length) {
363
+ return `
364
+ <pre class="mt-3 bg-slate-900/70 border border-slate-800 rounded-lg p-3 text-xs text-slate-300 whitespace-pre-wrap">${escapeHtml(JSON.stringify(obj, null, 2))}</pre>
365
+ `;
366
+ }
367
+ const threadId = obj.thread_id ? `<p class="text-xs text-slate-400">Thread: ${escapeHtml(obj.thread_id)}</p>` : "";
368
+ const provider = obj.provider ? `<p class="text-xs text-slate-400">Provider: ${escapeHtml(obj.provider)}</p>` : "";
369
+ const exchanges = transcript
370
+ .map((turn, index) => {
371
+ const user = turn.user
372
+ ? `<p class="text-sm text-slate-200"><span class="font-semibold">Human:</span> ${formatPlainText(turn.user)}</p>`
373
+ : "";
374
+ const assistant = turn.assistant
375
+ ? `<p class="text-sm text-sky-200 mt-2"><span class="font-semibold">AI:</span> ${formatPlainText(
376
+ turn.assistant
377
+ )}</p>`
378
+ : "";
379
+ return `
380
+ <div class="rounded-lg border border-slate-800 bg-slate-900/70 p-3 space-y-2">
381
+ <p class="text-xs text-slate-400 uppercase tracking-wide">Exchange ${index + 1}</p>
382
+ ${user}
383
+ ${assistant}
384
+ </div>
385
+ `;
386
+ })
387
+ .join("");
388
+ return `
389
+ <div class="mt-3 space-y-3">
390
+ ${threadId}
391
+ ${provider}
392
+ <div class="space-y-3">${exchanges}</div>
393
+ </div>
394
+ `;
395
+ }
396
+
397
+ function formatConversationContent(rawContent) {
398
+ if (rawContent == null) return "";
399
+ let value = rawContent;
400
+ if (typeof value === "string") {
401
+ value = tryParseJson(value);
402
+ }
403
+ if (typeof value === "string") {
404
+ return `<p class="text-sm text-slate-200 whitespace-pre-wrap">${formatPlainText(value)}</p>`;
405
+ }
406
+ if (Array.isArray(value)) {
407
+ return `
408
+ <pre class="mt-3 bg-slate-900/70 border border-slate-800 rounded-lg p-3 text-xs text-slate-300 whitespace-pre-wrap">${escapeHtml(
409
+ JSON.stringify(value, null, 2)
410
+ )}</pre>
411
+ `;
412
+ }
413
+ if (typeof value === "object") {
414
+ if (value && Array.isArray(value.transcript)) {
415
+ return renderTranscriptObject(value);
416
+ }
417
+ return `
418
+ <pre class="mt-3 bg-slate-900/70 border border-slate-800 rounded-lg p-3 text-xs text-slate-300 whitespace-pre-wrap">${escapeHtml(
419
+ JSON.stringify(value, null, 2)
420
+ )}</pre>
421
+ `;
422
+ }
423
+ return `<p class="text-sm text-slate-200">${escapeHtml(String(value))}</p>`;
424
+ }
425
+
426
+ function formatTraceSummary(trace) {
427
+ if (!trace) return "";
428
+ const parts = [];
429
+ if (trace.trace_id) parts.push(`Trace ID: ${trace.trace_id}`);
430
+ if (trace.iteration != null) parts.push(`Iteration: ${trace.iteration}`);
431
+ if (trace.persona) parts.push(`Persona: ${trace.persona}`);
432
+ const termination = trace.termination_reason;
433
+ if (termination) parts.push(`Termination: ${termination}`);
434
+ return parts.join(" · ");
435
+ }
436
+
437
+ function renderConversationTimeline() {
438
+ const container = document.getElementById("conversationTimeline");
439
+ const summaryEl = document.getElementById("conversationSummary");
440
+ if (!container) return;
441
+ container.innerHTML = "";
442
+ if (!Array.isArray(perTrace) || !perTrace.length || !state.conversationTraceId) {
443
+ if (summaryEl) summaryEl.textContent = "No conversation data available.";
444
+ return;
445
+ }
446
+ const trace = perTrace.find(
447
+ (item) =>
448
+ item.trace_id === state.conversationTraceId ||
449
+ `iteration-${item.iteration ?? 0}` === state.conversationTraceId
450
+ );
451
+ if (!trace) {
452
+ if (summaryEl) summaryEl.textContent = "Conversation data not found for the selected trace.";
453
+ return;
454
+ }
455
+ if (summaryEl) {
456
+ summaryEl.textContent = formatTraceSummary(trace);
457
+ }
458
+ const conversation = Array.isArray(trace.conversation) ? trace.conversation : [];
459
+ if (!conversation.length) {
460
+ container.innerHTML = `<p class="text-sm text-slate-400">No conversation recorded for this trace.</p>`;
461
+ return;
462
+ }
463
+ conversation.forEach((entry, index) => {
464
+ const role = entry.role || "unknown";
465
+ const isAssistant = role === "assistant";
466
+ const isUser = role === "user";
467
+ const bubbleClasses = [
468
+ "rounded-xl",
469
+ "p-4",
470
+ "border",
471
+ "shadow-inner",
472
+ isAssistant ? "bg-sky-500/10 border-sky-400/40" : "bg-slate-900 border-slate-800",
473
+ ].join(" ");
474
+ const roleLabel = isAssistant ? "AI" : isUser ? "Human" : role.toUpperCase();
475
+ const source = entry.source ? `<span class="text-xs uppercase tracking-wide text-slate-400">${escapeHtml(entry.source)}</span>` : "";
476
+ const metadata = entry.metadata || {};
477
+ const actions = Array.isArray(metadata.actions) ? metadata.actions.map((action) => escapeHtml(action)) : [];
478
+ const closing = metadata.closing ? " • Closing turn" : "";
479
+ const persona = metadata.persona ? ` • Persona: ${escapeHtml(metadata.persona)}` : "";
480
+ const detailLabel = actions.length ? actions.join(", ") : "No recorded actions for this turn.";
481
+ const detailContent = actions.length
482
+ ? actions.map((item) => `<li>${item}</li>`).join("")
483
+ : "";
484
+ const formattedContent = formatConversationContent(entry.content);
485
+ const detailsMarkup = `
486
+ <details class="mt-3 bg-slate-900/60 border border-slate-800 rounded-lg">
487
+ <summary class="cursor-pointer text-xs text-slate-300 px-3 py-2">Turn details</summary>
488
+ <div class="px-4 py-3 text-xs text-slate-300">
489
+ <p>${detailLabel}</p>
490
+ ${actions.length ? `<ul class="list-disc list-inside mt-2 space-y-1 text-slate-400">${detailContent}</ul>` : ""}
491
+ </div>
492
+ </details>
493
+ `;
494
+ container.innerHTML += `
495
+ <article class="${bubbleClasses}">
496
+ <header class="flex items-center justify-between gap-2 mb-2">
497
+ <div class="flex items-center gap-2">
498
+ <span class="text-sm font-semibold">${roleLabel}</span>
499
+ ${source}
500
+ </div>
501
+ <span class="text-xs text-slate-400">Turn ${entry.turn_index ?? index + 1}${closing}${persona}</span>
502
+ </header>
503
+ ${formattedContent}
504
+ ${detailsMarkup}
505
+ </article>
506
+ `;
507
+ });
508
+ }
509
+
510
+ function renderEvaluationGoal() {
511
+ const container = document.getElementById("evaluationGoal");
512
+ if (!container) return;
513
+ const goal = summary?.evaluation_goal;
514
+ if (!goal) {
515
+ container.textContent = "No evaluation goal provided.";
516
+ container.classList.remove("text-slate-300");
517
+ container.classList.add("text-slate-400");
518
+ return;
519
+ }
520
+ container.classList.remove("text-slate-400");
521
+ container.classList.add("text-slate-300");
522
+ container.textContent = goal;
523
+ }
524
+
217
525
  function renderCriteria() {
218
526
  const container = document.getElementById("criteriaList");
219
527
  if (!container || !criteria || !Object.keys(criteria).length) {
@@ -458,12 +766,14 @@ DEFAULT_TEMPLATE = r"""<!DOCTYPE html>
458
766
 
459
767
  initTabs();
460
768
  renderSummaryCards();
769
+ renderEvaluationGoal();
461
770
  renderCriteria();
462
771
  renderRecommendations();
463
772
  renderScoreChart();
464
773
  renderTopReasons();
465
774
  renderPersonaSummary();
466
775
  renderAnalysisContent();
776
+ initConversationSelect();
467
777
  initTraceFilter();
468
778
  </script>
469
779
  </body>
@@ -471,6 +781,45 @@ DEFAULT_TEMPLATE = r"""<!DOCTYPE html>
471
781
  """
472
782
 
473
783
 
784
+ def _resolve_experiment_label(summary: Dict[str, Any], output_path: Path) -> Optional[str]:
785
+ def _stringify(value: Any) -> Optional[str]:
786
+ if value is None:
787
+ return None
788
+ if isinstance(value, (list, tuple, set)):
789
+ parts = [str(item) for item in value if item not in (None, "")]
790
+ if not parts:
791
+ return None
792
+ return ", ".join(parts)
793
+ if isinstance(value, str):
794
+ return value.strip() or None
795
+ return str(value)
796
+
797
+ for key in ("experiment_id", "experiment_name"):
798
+ candidate = _stringify(summary.get(key))
799
+ if candidate:
800
+ return candidate
801
+
802
+ experiment_meta = summary.get("experiment")
803
+ if isinstance(experiment_meta, dict):
804
+ for key in ("name", "id", "label"):
805
+ candidate = _stringify(experiment_meta.get(key))
806
+ if candidate:
807
+ return candidate
808
+
809
+ output_dir = output_path.parent
810
+ if output_dir != output_path:
811
+ experiment_dir = output_dir.parent
812
+ if experiment_dir and experiment_dir != output_dir:
813
+ candidate = _stringify(experiment_dir.name)
814
+ if candidate:
815
+ return candidate
816
+ candidate = _stringify(output_dir.name)
817
+ if candidate:
818
+ return candidate
819
+
820
+ return None
821
+
822
+
474
823
  def select_html_template(options: "EvaluationOptions", config: "EvaluationConfig") -> Tuple[str, Optional[str]]:
475
824
  if options.report_template:
476
825
  template_text = load_template_from_path(options.report_template)
@@ -497,9 +846,20 @@ def write_html_report(
497
846
  success_criteria = summary.get("success_criteria_results") or {}
498
847
  analysis = summary.get("analysis") or {}
499
848
 
849
+ generated_at = datetime.now(UTC)
850
+ generated_iso = generated_at.isoformat(timespec="seconds").replace("+00:00", "Z")
851
+ human_readable = generated_at.strftime("%Y-%m-%d %H:%M:%SZ")
852
+ experiment_label = _resolve_experiment_label(summary, output_path)
853
+ subtitle_parts = [f"Generated on {human_readable}"]
854
+ if experiment_label:
855
+ subtitle_parts.append(f"Experiment: {experiment_label}")
856
+ subtitle = " · ".join(subtitle_parts)
857
+
500
858
  replacements = {
501
- "[[TITLE]]": summary.get("evaluation_goal") or "FluxLoop Evaluation Report",
502
- "[[DATE]]": datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z"),
859
+ "[[TITLE]]": "FluxLoop Evaluation Report",
860
+ "[[DATE]]": generated_iso,
861
+ "[[SUBTITLE]]": subtitle,
862
+ "[[LOGO_DATA_URI]]": FLUXLOOP_LOGO_DATA_URI,
503
863
  "[[SUMMARY_JSON]]": json.dumps(summary, ensure_ascii=False),
504
864
  "[[PER_TRACE_JSON]]": json.dumps(per_trace_payload, ensure_ascii=False),
505
865
  "[[CRITERIA_JSON]]": json.dumps(success_criteria, ensure_ascii=False),
@@ -515,6 +875,7 @@ def write_html_report(
515
875
 
516
876
  __all__ = [
517
877
  "DEFAULT_TEMPLATE",
878
+ "FLUXLOOP_LOGO_DATA_URI",
518
879
  "serialize_trace_outcome",
519
880
  "load_template_from_path",
520
881
  "select_html_template",
@@ -189,6 +189,172 @@ def _evaluate_latency_under(context: RuleContext, definition: RuleDefinition) ->
189
189
  return RuleResult(rule=definition, score=score, reason=reason)
190
190
 
191
191
 
192
+ def _coerce_usage_values(data: Dict[str, Any]) -> Dict[str, float]:
193
+ usage: Dict[str, float] = {}
194
+ for key in ("prompt_tokens", "completion_tokens", "total_tokens"):
195
+ value = data.get(key)
196
+ if isinstance(value, (int, float)):
197
+ usage[key] = float(value)
198
+ return usage
199
+
200
+
201
+ def _usage_from_candidate(candidate: Any) -> Dict[str, float]:
202
+ if not isinstance(candidate, dict):
203
+ return {}
204
+ direct = _coerce_usage_values(candidate)
205
+ if direct:
206
+ return direct
207
+ token_usage = candidate.get("token_usage")
208
+ if isinstance(token_usage, dict):
209
+ values = _coerce_usage_values(token_usage)
210
+ if values:
211
+ return values
212
+ nested = candidate.get("usage")
213
+ if isinstance(nested, dict):
214
+ return _coerce_usage_values(nested)
215
+ return {}
216
+
217
+
218
+ def _entry_token_usage(entry: Dict[str, Any]) -> Dict[str, float]:
219
+ candidates = [entry]
220
+ output = entry.get("output")
221
+ if isinstance(output, dict):
222
+ candidates.append(output)
223
+ messages = output.get("messages")
224
+ if isinstance(messages, dict):
225
+ candidates.append(messages)
226
+ response_metadata = messages.get("response_metadata")
227
+ if isinstance(response_metadata, dict):
228
+ candidates.append(response_metadata)
229
+ raw = entry.get("raw")
230
+ if isinstance(raw, dict):
231
+ candidates.append(raw)
232
+ raw_output = raw.get("output")
233
+ if isinstance(raw_output, dict):
234
+ candidates.append(raw_output)
235
+ response_metadata = raw_output.get("response_metadata")
236
+ if isinstance(response_metadata, dict):
237
+ candidates.append(response_metadata)
238
+ messages = raw_output.get("messages")
239
+ if isinstance(messages, dict):
240
+ candidates.append(messages)
241
+ response_metadata = messages.get("response_metadata")
242
+ if isinstance(response_metadata, dict):
243
+ candidates.append(response_metadata)
244
+ metadata = raw.get("metadata")
245
+ if isinstance(metadata, dict):
246
+ candidates.append(metadata)
247
+ response_metadata = raw.get("response_metadata")
248
+ if isinstance(response_metadata, dict):
249
+ candidates.append(response_metadata)
250
+ for candidate in candidates:
251
+ usage = _usage_from_candidate(candidate)
252
+ if usage:
253
+ return usage
254
+ return {}
255
+
256
+
257
+ def _collect_token_usage(trace: Dict[str, Any]) -> Optional[Dict[str, float]]:
258
+ totals = {"prompt_tokens": 0.0, "completion_tokens": 0.0, "total_tokens": 0.0}
259
+ found = False
260
+
261
+ summary = trace.get("summary")
262
+ if isinstance(summary, dict):
263
+ summary_usage = {}
264
+ if isinstance(summary.get("token_usage"), dict):
265
+ summary_usage = _usage_from_candidate(summary["token_usage"])
266
+ if not summary_usage:
267
+ raw = summary.get("raw")
268
+ if isinstance(raw, dict) and isinstance(raw.get("token_usage"), dict):
269
+ summary_usage = _usage_from_candidate(raw["token_usage"])
270
+ if summary_usage:
271
+ found = True
272
+ for key, value in summary_usage.items():
273
+ totals[key] += value
274
+
275
+ timeline = trace.get("timeline")
276
+ if isinstance(timeline, list):
277
+ for entry in timeline:
278
+ if not isinstance(entry, dict):
279
+ continue
280
+ usage = _entry_token_usage(entry)
281
+ if not usage:
282
+ continue
283
+ found = True
284
+ for key, value in usage.items():
285
+ totals[key] += value
286
+
287
+ if not found:
288
+ return None
289
+
290
+ prompt = totals["prompt_tokens"]
291
+ completion = totals["completion_tokens"]
292
+ total = totals["total_tokens"]
293
+ if total <= 0:
294
+ total = prompt + completion
295
+ return {
296
+ "prompt": prompt,
297
+ "completion": completion,
298
+ "total": total,
299
+ }
300
+
301
+
302
+ def _coerce_budget_value(value: Any, label: str) -> Optional[float]:
303
+ if value is None:
304
+ return None
305
+ try:
306
+ budget = float(value)
307
+ except (TypeError, ValueError) as exc: # noqa: BLE001
308
+ raise ValueError(f"token_usage_under {label} must be numeric") from exc
309
+ if budget <= 0:
310
+ raise ValueError(f"token_usage_under {label} must be greater than 0")
311
+ return budget
312
+
313
+
314
+ def _evaluate_token_usage_under(context: RuleContext, definition: RuleDefinition) -> RuleResult:
315
+ usage = _collect_token_usage(context.trace)
316
+ if usage is None:
317
+ return RuleResult(
318
+ rule=definition,
319
+ score=0.0,
320
+ reason="token usage data not available",
321
+ )
322
+
323
+ max_total = _coerce_budget_value(definition.params.get("max_total_tokens"), "max_total_tokens")
324
+ max_prompt = _coerce_budget_value(
325
+ definition.params.get("max_prompt_tokens"),
326
+ "max_prompt_tokens",
327
+ )
328
+ max_completion = _coerce_budget_value(
329
+ definition.params.get("max_completion_tokens"),
330
+ "max_completion_tokens",
331
+ )
332
+
333
+ if max_total is None and max_prompt is None and max_completion is None:
334
+ raise ValueError("token_usage_under requires at least one max_*_tokens parameter")
335
+
336
+ score = 1.0
337
+ reasons: List[str] = []
338
+
339
+ def apply_budget(actual: float, budget: Optional[float], label: str) -> None:
340
+ nonlocal score
341
+ if budget is None:
342
+ return
343
+ if actual <= budget:
344
+ return
345
+ ratio = budget / actual if actual > 0 else 0.0
346
+ ratio = max(0.0, min(1.0, ratio))
347
+ score = min(score, ratio)
348
+ reasons.append(f"{label} {actual:.0f} exceeds budget {budget:.0f}")
349
+
350
+ apply_budget(usage["total"], max_total, "total tokens")
351
+ apply_budget(usage["prompt"], max_prompt, "prompt tokens")
352
+ apply_budget(usage["completion"], max_completion, "completion tokens")
353
+
354
+ reason = "; ".join(reasons) if reasons else None
355
+ return RuleResult(rule=definition, score=score, reason=reason)
356
+
357
+
192
358
  def _evaluate_similarity(context: RuleContext, definition: RuleDefinition) -> RuleResult:
193
359
  target = definition.params.get("target", "output")
194
360
  expected_path = definition.params.get("expected_path")
@@ -233,6 +399,7 @@ RULE_DISPATCH = {
233
399
  "not_contains": _evaluate_not_contains,
234
400
  "matches_regex": _evaluate_matches_regex,
235
401
  "latency_under": _evaluate_latency_under,
402
+ "token_usage_under": _evaluate_token_usage_under,
236
403
  "similarity": _evaluate_similarity,
237
404
  "success": _evaluate_success_flag,
238
405
  }
fluxloop_cli/runner.py CHANGED
@@ -13,7 +13,7 @@ import os
13
13
  import time
14
14
  from datetime import datetime
15
15
  from pathlib import Path
16
- from typing import Any, Callable, Dict, List, Optional, Sequence
16
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple
17
17
 
18
18
  import fluxloop
19
19
  import yaml
@@ -375,6 +375,8 @@ class ExperimentRunner:
375
375
  if trace_id:
376
376
  observations = self._load_observations_for_trace(trace_id)
377
377
 
378
+ seen_observation_keys: Set[Tuple[Optional[str], Optional[str], Optional[str]]] = set()
379
+
378
380
  final_output = self._extract_final_output(callback_messages, observations)
379
381
  if final_output is not None:
380
382
  result = final_output
@@ -413,6 +415,38 @@ class ExperimentRunner:
413
415
  if observations:
414
416
  trace_entry["observation_count"] = len(observations)
415
417
 
418
+ # Build normalized conversation transcript
419
+ conversation: List[Dict[str, Any]] = []
420
+ turn_index = 1
421
+ conversation.append(
422
+ self._make_conversation_entry(
423
+ turn_index=turn_index,
424
+ role="user",
425
+ content=input_text,
426
+ source="input",
427
+ persona=persona.name if persona else None,
428
+ )
429
+ )
430
+ actions = self._summarize_observation_actions(observations, seen_observation_keys)
431
+ assistant_text = self._ensure_text(result)
432
+ trace_entry["output"] = assistant_text
433
+ conversation.append(
434
+ self._make_conversation_entry(
435
+ turn_index=turn_index,
436
+ role="assistant",
437
+ content=assistant_text,
438
+ source="agent",
439
+ actions=actions or None,
440
+ )
441
+ )
442
+ trace_entry["conversation"] = conversation
443
+ trace_entry["conversation_state"] = {
444
+ "turns": [
445
+ {"role": "user", "content": input_text},
446
+ {"role": "assistant", "content": assistant_text},
447
+ ]
448
+ }
449
+
416
450
  self.results["traces"].append(trace_entry)
417
451
 
418
452
  except Exception as e:
@@ -490,6 +524,19 @@ class ExperimentRunner:
490
524
  "context": {},
491
525
  }
492
526
 
527
+ normalized_conversation: List[Dict[str, Any]] = []
528
+ turn_index = 1
529
+ normalized_conversation.append(
530
+ self._make_conversation_entry(
531
+ turn_index=turn_index,
532
+ role="user",
533
+ content=input_text,
534
+ source="input",
535
+ persona=persona.name if persona else None,
536
+ )
537
+ )
538
+ seen_observation_keys: Set[Tuple[Optional[str], Optional[str], Optional[str]]] = set()
539
+
493
540
  trace_name = f"{self.config.name}_iter{iteration}"
494
541
  if persona:
495
542
  trace_name += f"_persona_{persona.name}"
@@ -569,14 +616,29 @@ class ExperimentRunner:
569
616
  assistant_output if isinstance(assistant_output, str) else str(assistant_output),
570
617
  )
571
618
 
572
- final_output = assistant_output
619
+ assistant_text = self._ensure_text(assistant_output)
620
+ final_output = assistant_text
573
621
 
574
622
  conversation_state["turns"].append(
575
623
  {
576
624
  "role": "assistant",
577
- "content": assistant_output,
625
+ "content": assistant_text,
578
626
  }
579
627
  )
628
+ new_actions = self._summarize_observation_actions(
629
+ observations,
630
+ seen_observation_keys,
631
+ )
632
+ normalized_conversation.append(
633
+ self._make_conversation_entry(
634
+ turn_index=turn_index,
635
+ role="assistant",
636
+ content=assistant_text,
637
+ source="agent",
638
+ actions=new_actions or None,
639
+ )
640
+ )
641
+ turn_index += 1
580
642
 
581
643
  turn_count += 1
582
644
  ctx.add_metadata("turn_count", turn_count)
@@ -606,13 +668,24 @@ class ExperimentRunner:
606
668
  decision.termination_reason or "supervisor_terminate"
607
669
  )
608
670
  if decision.closing_user_message:
671
+ closing_text = self._ensure_text(decision.closing_user_message)
609
672
  conversation_state["turns"].append(
610
673
  {
611
674
  "role": "user",
612
- "content": decision.closing_user_message,
675
+ "content": closing_text,
613
676
  "closing": True,
614
677
  }
615
678
  )
679
+ normalized_conversation.append(
680
+ self._make_conversation_entry(
681
+ turn_index=turn_index,
682
+ role="user",
683
+ content=closing_text,
684
+ source="supervisor",
685
+ persona=persona.name if persona else None,
686
+ closing=True,
687
+ )
688
+ )
616
689
  ctx.add_metadata("termination_reason", termination_reason)
617
690
  break
618
691
 
@@ -627,12 +700,22 @@ class ExperimentRunner:
627
700
  type(next_user_message).__name__,
628
701
  next_user_message if isinstance(next_user_message, str) else str(next_user_message),
629
702
  )
703
+ next_user_text = self._ensure_text(next_user_message)
630
704
  conversation_state["turns"].append(
631
705
  {
632
706
  "role": "user",
633
- "content": next_user_message,
707
+ "content": next_user_text,
634
708
  }
635
709
  )
710
+ normalized_conversation.append(
711
+ self._make_conversation_entry(
712
+ turn_index=turn_index,
713
+ role="user",
714
+ content=next_user_text,
715
+ source="supervisor" if decision.raw_response else "user",
716
+ persona=persona.name if persona else None,
717
+ )
718
+ )
636
719
  current_user_input = next_user_message
637
720
 
638
721
  EventBuffer.get_instance().flush()
@@ -664,7 +747,8 @@ class ExperimentRunner:
664
747
  "duration_ms": duration_ms,
665
748
  "success": True,
666
749
  "termination_reason": termination_reason,
667
- "conversation": conversation_state,
750
+ "conversation": normalized_conversation,
751
+ "conversation_state": conversation_state,
668
752
  }
669
753
  if last_decision and last_decision.raw_response:
670
754
  trace_entry["supervisor_response"] = last_decision.raw_response
@@ -969,6 +1053,75 @@ class ExperimentRunner:
969
1053
  "kwargs": kwargs,
970
1054
  }
971
1055
 
1056
+ @staticmethod
1057
+ def _ensure_text(value: Any) -> str:
1058
+ """Best-effort conversion of arbitrary values into displayable text."""
1059
+ if value is None:
1060
+ return ""
1061
+ if isinstance(value, str):
1062
+ return value
1063
+ try:
1064
+ return json.dumps(value, ensure_ascii=False)
1065
+ except (TypeError, ValueError):
1066
+ return str(value)
1067
+
1068
+ @staticmethod
1069
+ def _make_conversation_entry(
1070
+ *,
1071
+ turn_index: int,
1072
+ role: str,
1073
+ content: Any,
1074
+ source: str,
1075
+ persona: Optional[str] = None,
1076
+ closing: bool = False,
1077
+ actions: Optional[List[str]] = None,
1078
+ ) -> Dict[str, Any]:
1079
+ """Compose a normalized conversation entry."""
1080
+ entry: Dict[str, Any] = {
1081
+ "turn_index": turn_index,
1082
+ "role": role,
1083
+ "content": ExperimentRunner._ensure_text(content),
1084
+ "source": source,
1085
+ }
1086
+ metadata: Dict[str, Any] = {}
1087
+ if persona:
1088
+ metadata["persona"] = persona
1089
+ if closing:
1090
+ metadata["closing"] = True
1091
+ if actions:
1092
+ metadata["actions"] = actions
1093
+
1094
+ entry["metadata"] = metadata
1095
+ return entry
1096
+
1097
+ @staticmethod
1098
+ def _summarize_observation_actions(
1099
+ observations: Sequence[Dict[str, Any]],
1100
+ seen: Set[Tuple[Optional[str], Optional[str], Optional[str]]],
1101
+ ) -> List[str]:
1102
+ """Return new action descriptors discovered in observations, tracking seen items."""
1103
+ actions: List[str] = []
1104
+ for obs in sorted(
1105
+ observations,
1106
+ key=lambda item: (
1107
+ item.get("start_time") or "",
1108
+ item.get("end_time") or "",
1109
+ item.get("name") or "",
1110
+ ),
1111
+ ):
1112
+ key = (
1113
+ obs.get("id"),
1114
+ obs.get("start_time"),
1115
+ obs.get("name"),
1116
+ )
1117
+ if key in seen:
1118
+ continue
1119
+ seen.add(key)
1120
+ obs_type = obs.get("type") or "event"
1121
+ obs_name = obs.get("name") or obs_type
1122
+ actions.append(f"{obs_type}:{obs_name}")
1123
+ return actions
1124
+
972
1125
  async def _coerce_result_to_text(self, value: Any) -> Optional[str]:
973
1126
  """Best-effort conversion of agent result into text for summaries.
974
1127
 
@@ -1035,20 +1188,22 @@ class ExperimentRunner:
1035
1188
 
1036
1189
  with summary_path.open("w", encoding="utf-8") as summary_file:
1037
1190
  for trace in self.results["traces"]:
1038
- summary_file.write(
1039
- json.dumps(
1040
- {
1041
- "trace_id": trace.get("trace_id"),
1042
- "iteration": trace.get("iteration"),
1043
- "persona": trace.get("persona"),
1044
- "input": trace.get("input"),
1045
- "output": trace.get("output"),
1046
- "duration_ms": trace.get("duration_ms"),
1047
- "success": trace.get("success"),
1048
- }
1049
- )
1050
- + "\n"
1051
- )
1191
+ summary_payload = {
1192
+ "trace_id": trace.get("trace_id"),
1193
+ "iteration": trace.get("iteration"),
1194
+ "persona": trace.get("persona"),
1195
+ "input": trace.get("input"),
1196
+ "output": trace.get("output"),
1197
+ "duration_ms": trace.get("duration_ms"),
1198
+ "success": trace.get("success"),
1199
+ }
1200
+ if trace.get("conversation") is not None:
1201
+ summary_payload["conversation"] = trace.get("conversation")
1202
+ if trace.get("conversation_state") is not None:
1203
+ summary_payload["conversation_state"] = trace.get("conversation_state")
1204
+ if trace.get("termination_reason") is not None:
1205
+ summary_payload["termination_reason"] = trace.get("termination_reason")
1206
+ summary_file.write(json.dumps(summary_payload) + "\n")
1052
1207
 
1053
1208
  def _save_experiment_observations(self) -> None:
1054
1209
  """Copy matching observations from the offline store into the experiment directory."""
fluxloop_cli/templates.py CHANGED
@@ -216,13 +216,13 @@ def create_evaluation_config() -> str:
216
216
  rules:
217
217
  - check: output_not_empty
218
218
 
219
- - name: latency_budget
219
+ - name: token_budget
220
220
  type: rule_based
221
221
  enabled: true
222
222
  weight: 0.2
223
223
  rules:
224
- - check: latency_under
225
- budget_ms: 1500
224
+ - check: token_usage_under
225
+ max_total_tokens: 4000
226
226
 
227
227
  - name: keyword_quality
228
228
  type: rule_based
@@ -340,7 +340,7 @@ def create_evaluation_config() -> str:
340
340
  performance:
341
341
  all_traces_successful: true
342
342
  avg_response_time:
343
- enabled: true
343
+ enabled: false
344
344
  threshold_ms: 2000
345
345
  max_response_time:
346
346
  enabled: false
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fluxloop-cli
3
- Version: 0.2.26
3
+ Version: 0.2.28
4
4
  Summary: FluxLoop CLI for running agent simulations
5
5
  Author-email: FluxLoop Team <team@fluxloop.dev>
6
6
  License: Apache-2.0
@@ -1,4 +1,4 @@
1
- fluxloop_cli/__init__.py,sha256=DsDXIug3R14X0dB0kmPUgVsLwOunKnreu4hhfdoJ0Ts,143
1
+ fluxloop_cli/__init__.py,sha256=Yy0ysK8xslb79WaB0apu48ZBwviey964qWc5uDat8Mc,143
2
2
  fluxloop_cli/arg_binder.py,sha256=6-i81kK6kmR8inNvIp0GeGY51M5Jere-hAdignMl2bE,12765
3
3
  fluxloop_cli/config_loader.py,sha256=PYy0CfGVbU8jpPbx4sJzOu7i3BbrkQMNaRiSOp_uX9g,10307
4
4
  fluxloop_cli/config_schema.py,sha256=JZJRcMFun5hp3vKLAyek7W3NvISyzRzZt0BZAeSU38I,2415
@@ -9,9 +9,9 @@ fluxloop_cli/input_generator.py,sha256=ldlVdPSDfGsP9zO2RALk7QmZjkIvUzTaxDgwOjuPB
9
9
  fluxloop_cli/llm_generator.py,sha256=SosP5DeZuhBLEM6bj7BDp-7mckvVhtNJMEk2ZgV143M,12894
10
10
  fluxloop_cli/main.py,sha256=apLSNIgXv-V269ttKvd-t5za2UUjePswmR5KZGhOLPE,2772
11
11
  fluxloop_cli/project_paths.py,sha256=FoHp-g3aY1nytxGys85Oy3wJ6gmiKU6FVOwkgTtlHNA,4128
12
- fluxloop_cli/runner.py,sha256=jzXbI0TSxOrzX37I3d7MAmYyPBm0Uqi9P8M3p8HZ3Kk,43936
12
+ fluxloop_cli/runner.py,sha256=ee-ue9wmN7X3VKuIk1Sg3z_vYiKwypWkKxFsTccH6rA,50199
13
13
  fluxloop_cli/target_loader.py,sha256=ACCu2izqGKoOrEiNnAajH0FgLZcw3j1pWn5rAhEuWFU,5528
14
- fluxloop_cli/templates.py,sha256=pC02dyK62YBATf2QYtuj_WgAaIKsUvP3gCItHgfu9UQ,19267
14
+ fluxloop_cli/templates.py,sha256=SvJrz0xqZ7VxkXnQ4FgffiClLuno3tMAG5qNJF4SwKc,19277
15
15
  fluxloop_cli/validators.py,sha256=_bLXmxUSzVrDtLjqyTba0bDqamRIaOUHhV4xZ7K36Xw,1155
16
16
  fluxloop_cli/commands/__init__.py,sha256=i8xTg7uE9B6ExA0EP_j2h9DZWhD-eC94a-7khXIzRwo,243
17
17
  fluxloop_cli/commands/config.py,sha256=9moP68pcNBnJYGMcptgdU1UW-6WlI-Nan6eykPdwl8I,12121
@@ -19,21 +19,21 @@ fluxloop_cli/commands/doctor.py,sha256=svoz9wbLv5Byj9G1Y9GI5lYpVdoG2e7IzfG7tZxuM
19
19
  fluxloop_cli/commands/evaluate.py,sha256=itxb3TyfqV44DaCczC3oC94cJ2rmz2cIpY-794eXhvQ,7229
20
20
  fluxloop_cli/commands/generate.py,sha256=6tBanI8gj5Zzwz2cv1YeW60bYiO7iWivD0AgOcJ-GUU,7172
21
21
  fluxloop_cli/commands/init.py,sha256=eugHrsOTaXQmX5jxRNGSO3C3SpRVI5BCSqP9GqmGHPQ,8156
22
- fluxloop_cli/commands/parse.py,sha256=GIBkkcwyW-nyjJe_V64jGjickkyKQbR09gERlaqAGy4,12493
22
+ fluxloop_cli/commands/parse.py,sha256=5dgoHt6x2h7SBf6AOlbafoIWz3psEBrJdZyErIvgSgw,15417
23
23
  fluxloop_cli/commands/record.py,sha256=IfoXdFAentyerBrIdQeweYr_kM7DjGks2aoKjjqCZ9Q,5139
24
- fluxloop_cli/commands/run.py,sha256=lqiZ5qGh_PbFdtlBq3BIqm0oc2DFhfg3yWEjergvoTk,14345
24
+ fluxloop_cli/commands/run.py,sha256=OMT9XrDBDZACyQ_8tlUiqSIRq6QROH25Uc1PHZoEHyU,16849
25
25
  fluxloop_cli/commands/status.py,sha256=ERZrWoSP3V7dz5A_TEE5b8E0nGwsPggP4nXw4tLOzxE,7841
26
26
  fluxloop_cli/evaluation/__init__.py,sha256=voMYkA4S5Gb3GyvpTU8JnKu0novOyZBEag1cYEqYi1Q,759
27
27
  fluxloop_cli/evaluation/artifacts.py,sha256=zDVB-SSQZ1JYWLPHwrfz5AuCv46pUFZfEfFY_5doAjQ,2176
28
28
  fluxloop_cli/evaluation/config.py,sha256=r_rBpx9uYNZtdJVbJnsgohIATHTaqKX03hg4Vb051KM,24656
29
29
  fluxloop_cli/evaluation/llm.py,sha256=-0jHKPbaZTF5obO8x0zmKXnLWLg5zDqK4ryM0IDMVuE,14769
30
- fluxloop_cli/evaluation/rules.py,sha256=OwB0SqXMroKAYmys-Tb7VGIlwixYWqpFqB6ztSJb5Hs,8412
30
+ fluxloop_cli/evaluation/rules.py,sha256=CflbgpqHnguxy6YBBpIHwiS-3SyU0HAxU3obKbawQi4,14372
31
31
  fluxloop_cli/evaluation/engine/__init__.py,sha256=08untLwO5PlFSdqqsupsXAnFBcCEUaw22wcVBVZlJxA,145
32
32
  fluxloop_cli/evaluation/engine/analysis.py,sha256=dESfsHBSfL9jcEFVcnD7UMxU_G_cozqsx_adAfZmjk4,15432
33
- fluxloop_cli/evaluation/engine/core.py,sha256=PK71zEm277q55LpDqtDGeDqX4m6sEN0HTg1p9jtaSfc,13226
33
+ fluxloop_cli/evaluation/engine/core.py,sha256=kS8-yqMwF57hycITdKjrmLy3sbBOv-bDzj1QhM5AQTA,13676
34
34
  fluxloop_cli/evaluation/engine/success.py,sha256=BVPi-W-RJtymGXhzOfS2x0VF3UTAaoZ44LEoIy2flIQ,10930
35
35
  fluxloop_cli/evaluation/engine/reporting/__init__.py,sha256=1e-lBuUuZ5n4fFnNTN-eyeHI-W0XGNaSCUb1kLzeIYs,1634
36
- fluxloop_cli/evaluation/engine/reporting/html.py,sha256=vv4uhnZiEAVFCP8LPJ0GOPZ1Ttdy2dybdNJdX_F2Uno,20409
36
+ fluxloop_cli/evaluation/engine/reporting/html.py,sha256=xG4l5wIXF4_T9Wjt9mcjOdp-yE1y3EADij4IPpyndSQ,37387
37
37
  fluxloop_cli/evaluation/engine/reporting/markdown.py,sha256=HSe15m6_rPCnd-4QNKDVy0qlqfzgHOPgyQzrkXzXDfI,7296
38
38
  fluxloop_cli/evaluation/prompts/__init__.py,sha256=0pQGcZkZXM4ZgZU4B1Lvdc6W2ii0KUNr5g0vcns8F8k,1326
39
39
  fluxloop_cli/evaluation/prompts/base.py,sha256=SL-wYCwCdfFr2VqgaXVQB6pR2PcVSf9O-aHhJ3mrtoI,1319
@@ -41,8 +41,8 @@ fluxloop_cli/evaluation/prompts/information_completeness.py,sha256=BjZ-wEIANWWBQ
41
41
  fluxloop_cli/evaluation/prompts/intent_recognition.py,sha256=QlroUq1y0wwWsT2qcS1NXdo7OwNqUu4Tw2xx7Frr50Q,1572
42
42
  fluxloop_cli/evaluation/prompts/response_clarity.py,sha256=1OYyx39UfS-YR__VdqhLejHNxH1NBVFm7Bha3MuMN9Y,1418
43
43
  fluxloop_cli/evaluation/prompts/response_consistency.py,sha256=tyO3Wsf2J_CvErCuNc2bw-UQWqRW_BupaBj3CS66_Yk,1550
44
- fluxloop_cli-0.2.26.dist-info/METADATA,sha256=dlH0AMgT0745fO--U4immDQl7tPP0K27B0pJYj1lsuM,6329
45
- fluxloop_cli-0.2.26.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
46
- fluxloop_cli-0.2.26.dist-info/entry_points.txt,sha256=NxOEMku4yLMY5kp_Qcd3JcevfXP6A98FsSf9xHcwkyE,51
47
- fluxloop_cli-0.2.26.dist-info/top_level.txt,sha256=ahLkaxzwhmVU4z-YhkmQVzAbW3-wez9cKnwPiDK7uKM,13
48
- fluxloop_cli-0.2.26.dist-info/RECORD,,
44
+ fluxloop_cli-0.2.28.dist-info/METADATA,sha256=Vs5b3XsEL5irrC2_8DzahTy1bfD-O3JznpzTqB5hxsY,6329
45
+ fluxloop_cli-0.2.28.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
46
+ fluxloop_cli-0.2.28.dist-info/entry_points.txt,sha256=NxOEMku4yLMY5kp_Qcd3JcevfXP6A98FsSf9xHcwkyE,51
47
+ fluxloop_cli-0.2.28.dist-info/top_level.txt,sha256=ahLkaxzwhmVU4z-YhkmQVzAbW3-wez9cKnwPiDK7uKM,13
48
+ fluxloop_cli-0.2.28.dist-info/RECORD,,