fluxloop-cli 0.2.20__tar.gz → 0.2.22__tar.gz

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.
Files changed (63) hide show
  1. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/PKG-INFO +13 -1
  2. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/README.md +12 -0
  3. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/__init__.py +1 -1
  4. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/arg_binder.py +102 -22
  5. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/commands/evaluate.py +16 -0
  6. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/commands/init.py +8 -4
  7. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/commands/run.py +86 -0
  8. fluxloop_cli-0.2.22/fluxloop_cli/conversation_supervisor.py +258 -0
  9. fluxloop_cli-0.2.22/fluxloop_cli/environment.py +71 -0
  10. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/evaluation/config.py +15 -1
  11. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/evaluation/engine/reporting/html.py +14 -14
  12. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/evaluation/llm.py +34 -0
  13. fluxloop_cli-0.2.22/fluxloop_cli/evaluation/prompts/__init__.py +53 -0
  14. fluxloop_cli-0.2.22/fluxloop_cli/evaluation/prompts/base.py +48 -0
  15. fluxloop_cli-0.2.22/fluxloop_cli/evaluation/prompts/information_completeness.py +45 -0
  16. fluxloop_cli-0.2.22/fluxloop_cli/evaluation/prompts/intent_recognition.py +46 -0
  17. fluxloop_cli-0.2.22/fluxloop_cli/evaluation/prompts/response_clarity.py +44 -0
  18. fluxloop_cli-0.2.22/fluxloop_cli/evaluation/prompts/response_consistency.py +46 -0
  19. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/runner.py +231 -14
  20. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/templates.py +31 -6
  21. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli.egg-info/PKG-INFO +13 -1
  22. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli.egg-info/SOURCES.txt +8 -0
  23. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/pyproject.toml +1 -1
  24. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/tests/test_arg_binder.py +48 -1
  25. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/tests/test_evaluate_command.py +48 -0
  26. fluxloop_cli-0.2.22/tests/test_run_command.py +72 -0
  27. fluxloop_cli-0.2.20/fluxloop_cli/evaluation/prompts/__init__.py +0 -182
  28. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/commands/__init__.py +0 -0
  29. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/commands/config.py +0 -0
  30. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/commands/doctor.py +0 -0
  31. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/commands/generate.py +0 -0
  32. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/commands/parse.py +0 -0
  33. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/commands/record.py +0 -0
  34. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/commands/status.py +0 -0
  35. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/config_loader.py +0 -0
  36. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/config_schema.py +0 -0
  37. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/constants.py +0 -0
  38. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/evaluation/__init__.py +0 -0
  39. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/evaluation/engine/__init__.py +0 -0
  40. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/evaluation/engine/analysis.py +0 -0
  41. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/evaluation/engine/core.py +0 -0
  42. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/evaluation/engine/reporting/__init__.py +0 -0
  43. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/evaluation/engine/reporting/markdown.py +0 -0
  44. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/evaluation/engine/success.py +0 -0
  45. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/evaluation/rules.py +0 -0
  46. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/input_generator.py +0 -0
  47. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/llm_generator.py +0 -0
  48. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/main.py +0 -0
  49. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/project_paths.py +0 -0
  50. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/target_loader.py +0 -0
  51. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/validators.py +0 -0
  52. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli.egg-info/dependency_links.txt +0 -0
  53. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli.egg-info/entry_points.txt +0 -0
  54. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli.egg-info/requires.txt +0 -0
  55. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli.egg-info/top_level.txt +0 -0
  56. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/setup.cfg +0 -0
  57. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/tests/test_analysis_recommendations.py +0 -0
  58. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/tests/test_config_command.py +0 -0
  59. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/tests/test_evaluation_llm.py +0 -0
  60. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/tests/test_input_generator.py +0 -0
  61. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/tests/test_prompt_library.py +0 -0
  62. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/tests/test_success_criteria.py +0 -0
  63. {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/tests/test_target_loader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fluxloop-cli
3
- Version: 0.2.20
3
+ Version: 0.2.22
4
4
  Summary: FluxLoop CLI for running agent simulations
5
5
  Author-email: FluxLoop Team <team@fluxloop.dev>
6
6
  License: Apache-2.0
@@ -71,6 +71,18 @@ The legacy `setting.yaml` is still supported, but new projects created with
71
71
  - `fluxloop record enable|disable|status` – toggle recording mode across `.env` and simulation config
72
72
  - `fluxloop doctor` – summarize Python, FluxLoop CLI/MCP, and MCP index state for the active environment
73
73
 
74
+ ### Multi-turn supervisor options
75
+
76
+ `fluxloop run experiment` supports multi-turn orchestration out of the box:
77
+
78
+ - Toggle with `--multi-turn/--no-multi-turn`
79
+ - Limit depth via `--max-turns`
80
+ - Control tool approvals with `--auto-approve-tools/--manual-approve-tools`
81
+ - Override the supervisor persona target: `--persona-override`
82
+ - Point at a specific LLM: `--supervisor-provider`, `--supervisor-model`, `--supervisor-temperature`, `--supervisor-api-key`
83
+
84
+ These flags override the values in `configs/simulation.yaml` (`multi_turn` block). When enabled, the runner consults the supervisor after every turn to decide whether to continue and to synthesize the next realistic user message.
85
+
74
86
  Run `fluxloop --help` or `fluxloop <command> --help` for more detail.
75
87
 
76
88
  ## Quick Setup Script
@@ -31,6 +31,18 @@ The legacy `setting.yaml` is still supported, but new projects created with
31
31
  - `fluxloop record enable|disable|status` – toggle recording mode across `.env` and simulation config
32
32
  - `fluxloop doctor` – summarize Python, FluxLoop CLI/MCP, and MCP index state for the active environment
33
33
 
34
+ ### Multi-turn supervisor options
35
+
36
+ `fluxloop run experiment` supports multi-turn orchestration out of the box:
37
+
38
+ - Toggle with `--multi-turn/--no-multi-turn`
39
+ - Limit depth via `--max-turns`
40
+ - Control tool approvals with `--auto-approve-tools/--manual-approve-tools`
41
+ - Override the supervisor persona target: `--persona-override`
42
+ - Point at a specific LLM: `--supervisor-provider`, `--supervisor-model`, `--supervisor-temperature`, `--supervisor-api-key`
43
+
44
+ These flags override the values in `configs/simulation.yaml` (`multi_turn` block). When enabled, the runner consults the supervisor after every turn to decide whether to continue and to synthesize the next realistic user message.
45
+
34
46
  Run `fluxloop --help` or `fluxloop <command> --help` for more detail.
35
47
 
36
48
  ## Quick Setup Script
@@ -2,7 +2,7 @@
2
2
  FluxLoop CLI - Command-line interface for running agent simulations.
3
3
  """
4
4
 
5
- __version__ = "0.2.20"
5
+ __version__ = "0.2.22"
6
6
 
7
7
  from .main import app
8
8
 
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import inspect
6
6
  import json
7
7
  from pathlib import Path
8
- from typing import Any, Callable, Dict, Optional
8
+ from typing import Any, Callable, Dict, Optional, Sequence
9
9
 
10
10
 
11
11
  class _AttrDict(dict):
@@ -26,7 +26,7 @@ class _AttrDict(dict):
26
26
  except KeyError as exc: # pragma: no cover
27
27
  raise AttributeError(item) from exc
28
28
 
29
- from fluxloop.schemas import ExperimentConfig, ReplayArgsConfig
29
+ from fluxloop.schemas import ExperimentConfig, ReplayArgsConfig, PersonaConfig
30
30
 
31
31
 
32
32
  class _AwaitableNone:
@@ -98,9 +98,17 @@ class ArgBinder:
98
98
  *,
99
99
  runtime_input: str,
100
100
  iteration: int = 0,
101
+ conversation_state: Optional[Dict[str, Any]] = None,
102
+ persona: Optional[PersonaConfig] = None,
103
+ auto_approve: Optional[bool] = None,
101
104
  ) -> Dict[str, Any]:
102
105
  """Construct kwargs for calling *func* based on replay or inspection."""
103
106
 
107
+ signature = inspect.signature(func)
108
+ parameters = list(signature.parameters.values())
109
+ if parameters and parameters[0].name == "self":
110
+ parameters = parameters[1:]
111
+
104
112
  if self._recording:
105
113
  kwargs = self._recording.get("kwargs", {}).copy()
106
114
 
@@ -111,22 +119,42 @@ class ArgBinder:
111
119
  try:
112
120
  self._set_by_path(kwargs, replay.override_param_path, runtime_input)
113
121
  except (KeyError, TypeError):
114
- # If path missing, fall back to plain binding
115
- return self._bind_by_signature(func, runtime_input)
116
-
122
+ kwargs = self._bind_runtime_input(parameters, runtime_input)
123
+ else:
124
+ fallback = self._bind_runtime_input(parameters, runtime_input)
125
+ for key, value in fallback.items():
126
+ kwargs.setdefault(key, value)
117
127
  self._restore_callables(kwargs, replay)
118
128
  self._ensure_no_unmapped_callables(kwargs, replay)
119
- return self._hydrate_structures(kwargs)
120
-
121
- return self._bind_by_signature(func, runtime_input)
122
-
123
- def _bind_by_signature(self, func: Callable, runtime_input: str) -> Dict[str, Any]:
124
- signature = inspect.signature(func)
125
- parameters = list(signature.parameters.values())
129
+ kwargs = self._hydrate_structures(kwargs)
130
+ else:
131
+ kwargs = self._bind_runtime_input(parameters, runtime_input)
132
+
133
+ return self._inject_optional_kwargs(
134
+ parameters=parameters,
135
+ kwargs=kwargs,
136
+ conversation_state=conversation_state,
137
+ persona=persona,
138
+ auto_approve=auto_approve,
139
+ iteration=iteration,
140
+ )
126
141
 
127
- if parameters and parameters[0].name == "self":
128
- parameters = parameters[1:]
142
+ def _bind_runtime_input(
143
+ self, parameters: Sequence[inspect.Parameter], runtime_input: str
144
+ ) -> Dict[str, Any]:
145
+ candidate = self._find_runtime_parameter(parameters)
146
+ if candidate:
147
+ return {candidate: runtime_input}
148
+ if parameters:
149
+ return {parameters[0].name: runtime_input}
150
+ raise ValueError(
151
+ "Cannot determine where to bind runtime input for the provided function."
152
+ )
129
153
 
154
+ @staticmethod
155
+ def _find_runtime_parameter(
156
+ parameters: Sequence[inspect.Parameter],
157
+ ) -> Optional[str]:
130
158
  candidate_names = [
131
159
  "input",
132
160
  "input_text",
@@ -134,18 +162,70 @@ class ArgBinder:
134
162
  "query",
135
163
  "text",
136
164
  "content",
165
+ "user_message",
137
166
  ]
167
+ for name in candidate_names:
168
+ for param in parameters:
169
+ if param.name == name:
170
+ return name
171
+ return None
138
172
 
139
- for param in parameters:
140
- if param.name in candidate_names:
141
- return {param.name: runtime_input}
173
+ def _inject_optional_kwargs(
174
+ self,
175
+ *,
176
+ parameters: Sequence[inspect.Parameter],
177
+ kwargs: Dict[str, Any],
178
+ conversation_state: Optional[Dict[str, Any]],
179
+ persona: Optional[PersonaConfig],
180
+ auto_approve: Optional[bool],
181
+ iteration: Optional[int],
182
+ ) -> Dict[str, Any]:
183
+ param_names = {param.name for param in parameters}
184
+
185
+ def assign(value: Any, candidates: Sequence[str]) -> bool:
186
+ if value is None:
187
+ return False
188
+ for name in candidates:
189
+ if name in param_names and name not in kwargs:
190
+ kwargs[name] = value
191
+ return True
192
+ return False
193
+
194
+ if conversation_state is not None:
195
+ assign(conversation_state, ["conversation_state", "state", "dialog_state"])
196
+ if isinstance(conversation_state, dict):
197
+ metadata = conversation_state.get("metadata")
198
+ if metadata:
199
+ assign(
200
+ metadata,
201
+ ["conversation_metadata", "state_metadata", "conversation_meta"],
202
+ )
203
+ turns = conversation_state.get("turns")
204
+ if turns:
205
+ assign(turns, ["messages", "history", "turns"])
206
+
207
+ if persona is not None:
208
+ assign(persona, ["persona", "user_persona", "persona_config"])
209
+ try:
210
+ persona_prompt = persona.to_prompt()
211
+ except Exception:
212
+ persona_prompt = None
213
+ if persona_prompt:
214
+ assign(
215
+ persona_prompt,
216
+ ["persona_prompt", "persona_description", "persona_text"],
217
+ )
218
+
219
+ if auto_approve is not None:
220
+ assign(
221
+ auto_approve,
222
+ ["auto_approve", "auto_approve_tools", "approve_tools", "autoapprove"],
223
+ )
142
224
 
143
- if parameters:
144
- return {parameters[0].name: runtime_input}
225
+ if iteration is not None:
226
+ assign(iteration, ["iteration", "run_iteration", "loop_index"])
145
227
 
146
- raise ValueError(
147
- f"Cannot determine where to bind runtime input for function '{func.__name__}'."
148
- )
228
+ return kwargs
149
229
 
150
230
  def _restore_callables(self, kwargs: Dict[str, Any], replay: ReplayArgsConfig) -> None:
151
231
  for param_name, provider in replay.callable_providers.items():
@@ -2,12 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  from pathlib import Path
6
7
  from typing import Optional
7
8
 
8
9
  import typer
9
10
  from rich.console import Console
10
11
 
12
+ from ..environment import load_env_chain
11
13
  from ..evaluation import EvaluationOptions, load_evaluation_config, run_evaluation
12
14
 
13
15
  console = Console()
@@ -134,6 +136,20 @@ def experiment(
134
136
  except Exception as exc: # noqa: BLE001
135
137
  raise typer.BadParameter(f"Failed to load evaluation config: {exc}") from exc
136
138
 
139
+ def _log_env_error(path: Path, exc: Exception) -> None:
140
+ console.log(
141
+ f"[yellow]Warning:[/yellow] Failed to load environment from {path}: {exc}"
142
+ )
143
+
144
+ load_env_chain(
145
+ evaluation_config.get_source_dir(),
146
+ refresh_config=True,
147
+ on_error=_log_env_error,
148
+ )
149
+
150
+ if llm_api_key is None:
151
+ llm_api_key = os.getenv("FLUXLOOP_LLM_API_KEY") or os.getenv("OPENAI_API_KEY")
152
+
137
153
  output_dir = output
138
154
  if not output_dir.is_absolute():
139
155
  output_dir = resolved_experiment_dir / output_dir
@@ -179,12 +179,16 @@ def project(
179
179
  # Show next steps
180
180
  console.print("\n[bold]Next steps:[/bold]")
181
181
  console.print("1. Review configs in [cyan]configs/[/cyan] (project/input/simulation/evaluation)")
182
- console.print("2. Set up environment variables in [cyan].env[/cyan]")
182
+ console.print("2. Configure secrets via [cyan].env[/cyan] or [green]fluxloop config set-llm[/green]")
183
183
  if with_example:
184
- console.print("3. Try running: [green]fluxloop run experiment[/green]")
184
+ console.print("3. Customize the sample agent in [cyan]examples/simple_agent.py[/cyan]")
185
185
  else:
186
- console.print("3. Add your agent code")
187
- console.print("4. Run: [green]fluxloop run experiment[/green]")
186
+ console.print("3. Create your agent: [green]fluxloop init agent <name>[/green]")
187
+ console.print("4. Generate inputs: [green]fluxloop generate inputs[/green]")
188
+ console.print("5. Run the experiment: [green]fluxloop run experiment[/green]")
189
+ console.print("6. Parse outputs: [green]fluxloop parse experiment[/green]")
190
+ console.print("7. Evaluate results (optional): [green]fluxloop evaluate experiment[/green]")
191
+ console.print("8. Diagnose environment anytime: [green]fluxloop doctor[/green]")
188
192
 
189
193
 
190
194
  @app.command()
@@ -21,6 +21,7 @@ from ..project_paths import (
21
21
  resolve_config_path,
22
22
  resolve_project_relative,
23
23
  )
24
+ from fluxloop.schemas import MultiTurnConfig
24
25
 
25
26
  app = typer.Typer()
26
27
  console = Console()
@@ -56,6 +57,46 @@ def experiment(
56
57
  "-p",
57
58
  help="Comma-separated list of personas to use",
58
59
  ),
60
+ multi_turn: Optional[bool] = typer.Option(
61
+ None,
62
+ "--multi-turn/--no-multi-turn",
63
+ help="Enable or disable multi-turn supervisor loop",
64
+ ),
65
+ max_turns: Optional[int] = typer.Option(
66
+ None,
67
+ "--max-turns",
68
+ help="Override maximum number of turns per conversation",
69
+ ),
70
+ auto_approve_tools: Optional[bool] = typer.Option(
71
+ None,
72
+ "--auto-approve-tools/--manual-approve-tools",
73
+ help="Override automatic tool approval behaviour",
74
+ ),
75
+ persona_override: Optional[str] = typer.Option(
76
+ None,
77
+ "--persona-override",
78
+ help="Force a specific persona id during multi-turn execution",
79
+ ),
80
+ supervisor_provider: Optional[str] = typer.Option(
81
+ None,
82
+ "--supervisor-provider",
83
+ help="Override conversation supervisor provider (e.g. openai, mock)",
84
+ ),
85
+ supervisor_model: Optional[str] = typer.Option(
86
+ None,
87
+ "--supervisor-model",
88
+ help="Override conversation supervisor model identifier",
89
+ ),
90
+ supervisor_temperature: Optional[float] = typer.Option(
91
+ None,
92
+ "--supervisor-temperature",
93
+ help="Override conversation supervisor sampling temperature",
94
+ ),
95
+ supervisor_api_key: Optional[str] = typer.Option(
96
+ None,
97
+ "--supervisor-api-key",
98
+ help="API key to use for supervisor calls (overrides environment)",
99
+ ),
59
100
  output_dir: Optional[Path] = typer.Option(
60
101
  None,
61
102
  "--output",
@@ -114,6 +155,39 @@ def experiment(
114
155
  if output_dir:
115
156
  resolved_output = resolve_project_relative(output_dir, project, root)
116
157
  config.output_directory = str(resolved_output)
158
+
159
+ if any(
160
+ value is not None
161
+ for value in [
162
+ multi_turn,
163
+ max_turns,
164
+ auto_approve_tools,
165
+ persona_override,
166
+ supervisor_provider,
167
+ supervisor_model,
168
+ supervisor_temperature,
169
+ supervisor_api_key,
170
+ ]
171
+ ):
172
+ if config.multi_turn is None:
173
+ config.multi_turn = MultiTurnConfig()
174
+ mt = config.multi_turn
175
+ if multi_turn is not None:
176
+ mt.enabled = multi_turn
177
+ if max_turns is not None:
178
+ mt.max_turns = max_turns
179
+ if auto_approve_tools is not None:
180
+ mt.auto_approve_tools = auto_approve_tools
181
+ if persona_override:
182
+ mt.persona_override = persona_override
183
+ if supervisor_provider:
184
+ mt.supervisor.provider = supervisor_provider
185
+ if supervisor_model:
186
+ mt.supervisor.model = supervisor_model
187
+ if supervisor_temperature is not None:
188
+ mt.supervisor.temperature = supervisor_temperature
189
+ if supervisor_api_key:
190
+ mt.supervisor.api_key = supervisor_api_key
117
191
 
118
192
  # Load inputs to ensure accurate counts before showing the summary
119
193
  try:
@@ -137,6 +211,18 @@ def experiment(
137
211
  "Input Source",
138
212
  "external file" if config.has_external_inputs() else "base_inputs",
139
213
  )
214
+ if config.multi_turn:
215
+ summary.add_row(
216
+ "Multi-turn",
217
+ "enabled" if config.multi_turn.enabled else "disabled",
218
+ )
219
+ summary.add_row("Max Turns", str(config.multi_turn.max_turns))
220
+ if config.multi_turn.persona_override:
221
+ summary.add_row("Persona Override", config.multi_turn.persona_override)
222
+ summary.add_row(
223
+ "Supervisor Model",
224
+ config.multi_turn.supervisor.model,
225
+ )
140
226
  summary.add_row("Total Runs", str(total_runs))
141
227
  summary.add_row("Output", config.output_directory)
142
228
 
@@ -0,0 +1,258 @@
1
+ """
2
+ Conversation supervisor for multi-turn simulations.
3
+
4
+ This module encapsulates the logic required to consult an LLM (or mock strategy)
5
+ to decide whether a conversation should continue and, if so, to generate the
6
+ next user utterance consistent with the experiment persona and service context.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ from dataclasses import dataclass
15
+ from typing import Any, Dict, List, Literal, Optional, Sequence
16
+
17
+ import httpx
18
+
19
+ from fluxloop.schemas import MultiTurnSupervisorConfig
20
+
21
+ SupervisorDecisionType = Literal["continue", "terminate"]
22
+
23
+
24
+ @dataclass
25
+ class SupervisorDecision:
26
+ """Structured decision returned by the conversation supervisor."""
27
+
28
+ decision: SupervisorDecisionType
29
+ next_user_message: Optional[str] = None
30
+ termination_reason: Optional[str] = None
31
+ closing_user_message: Optional[str] = None
32
+ raw_response: Optional[Dict[str, Any]] = None
33
+
34
+ def requires_follow_up(self) -> bool:
35
+ return self.decision == "continue"
36
+
37
+
38
+ def format_transcript(turns: Sequence[Dict[str, Any]]) -> str:
39
+ """Render a conversation transcript into a bullet list for supervisor prompts."""
40
+
41
+ lines: List[str] = []
42
+ for idx, turn in enumerate(turns, start=1):
43
+ role = turn.get("role", "unknown").capitalize()
44
+ content = turn.get("content") or ""
45
+ content = content.strip()
46
+
47
+ if turn.get("tool_calls"):
48
+ tool_calls = turn["tool_calls"]
49
+ if isinstance(tool_calls, list):
50
+ tool_summaries = []
51
+ for call in tool_calls:
52
+ name = call.get("name") or call.get("tool") or "tool"
53
+ args = call.get("arguments") or call.get("args") or {}
54
+ tool_summaries.append(f"{name}({json.dumps(args, ensure_ascii=False)})")
55
+ if tool_summaries:
56
+ content = f"{content}\n [Tool Calls] " + "; ".join(tool_summaries)
57
+ lines.append(f"{idx}. {role}: {content}")
58
+
59
+ return "\n".join(lines)
60
+
61
+
62
+ def build_supervisor_prompt(
63
+ turns: Sequence[Dict[str, Any]],
64
+ persona_description: Optional[str],
65
+ service_context: Optional[str],
66
+ instructions: Optional[str],
67
+ ) -> str:
68
+ """Create the textual prompt sent to the supervisor LLM."""
69
+
70
+ persona_text = persona_description or "Generic customer"
71
+ service_text = service_context or "Customer support scenario"
72
+ transcript_text = format_transcript(turns)
73
+
74
+ guidance = instructions or (
75
+ "You supervise an AI assistant. Review the transcript and decide whether the "
76
+ "conversation should continue. When continuing, craft the next user message "
77
+ "consistent with the persona. When terminating, explain why and provide any "
78
+ "closing notes."
79
+ )
80
+
81
+ prompt = (
82
+ f"{guidance}\n\n"
83
+ f"Service Context: {service_text}\n"
84
+ f"Persona: {persona_text}\n\n"
85
+ "Transcript so far:\n"
86
+ f"{transcript_text}\n\n"
87
+ "Respond ONLY in JSON with the following schema:\n"
88
+ "{\n"
89
+ ' "decision": "continue" | "terminate",\n'
90
+ ' "next_user_message": string | null,\n'
91
+ ' "termination_reason": string | null,\n'
92
+ ' "closing_user_message": string | null\n'
93
+ "}\n"
94
+ )
95
+
96
+ return prompt
97
+
98
+
99
+ class ConversationSupervisor:
100
+ """High-level interface for querying the conversation supervisor LLM."""
101
+
102
+ def __init__(self, config: MultiTurnSupervisorConfig):
103
+ self.config = config
104
+
105
+ async def decide(
106
+ self,
107
+ *,
108
+ conversation_state: Dict[str, Any],
109
+ persona_description: Optional[str],
110
+ service_context: Optional[str],
111
+ ) -> SupervisorDecision:
112
+ turns: Sequence[Dict[str, Any]] = conversation_state.get("turns", [])
113
+ prompt = build_supervisor_prompt(
114
+ turns=turns,
115
+ persona_description=persona_description,
116
+ service_context=service_context,
117
+ instructions=self.config.system_prompt,
118
+ )
119
+
120
+ provider = (self.config.provider or "openai").lower()
121
+ if provider == "mock":
122
+ return self._mock_decision()
123
+ if provider == "openai":
124
+ response_text = await self._call_openai(prompt)
125
+ else:
126
+ raise ValueError(f"Unsupported supervisor provider: {self.config.provider}")
127
+
128
+ return self._parse_decision(response_text)
129
+
130
+ def _mock_decision(self) -> SupervisorDecision:
131
+ """Deterministic supervisor used for tests or offline runs."""
132
+
133
+ metadata = self.config.metadata or {}
134
+ default_decision = metadata.get("mock_decision", "terminate")
135
+ decision: SupervisorDecisionType = (
136
+ default_decision if default_decision in {"continue", "terminate"} else "terminate"
137
+ )
138
+
139
+ if decision == "continue":
140
+ next_message = metadata.get(
141
+ "mock_next_user_message",
142
+ "Could you provide a bit more detail about that?",
143
+ )
144
+ return SupervisorDecision(
145
+ decision="continue",
146
+ next_user_message=next_message,
147
+ raw_response={"mock": True},
148
+ )
149
+
150
+ return SupervisorDecision(
151
+ decision="terminate",
152
+ termination_reason=metadata.get("mock_reason", "Supervisor mock termination"),
153
+ closing_user_message=metadata.get(
154
+ "mock_closing",
155
+ "Thanks for the help. I have no further questions.",
156
+ ),
157
+ raw_response={"mock": True},
158
+ )
159
+
160
+ async def _call_openai(self, prompt: str) -> str:
161
+ """Call OpenAI chat completions endpoint."""
162
+
163
+ api_key = (
164
+ self.config.api_key
165
+ or self.config.metadata.get("api_key")
166
+ or os.getenv("FLUXLOOP_SUPERVISOR_API_KEY")
167
+ or os.getenv("OPENAI_API_KEY")
168
+ )
169
+
170
+ if not api_key:
171
+ raise RuntimeError(
172
+ "OpenAI supervisor requires an API key. "
173
+ "Set multi_turn.supervisor.api_key or FLUXLOOP_SUPERVISOR_API_KEY/OPENAI_API_KEY."
174
+ )
175
+
176
+ messages: List[Dict[str, str]] = []
177
+ if self.config.system_prompt:
178
+ messages.append({"role": "system", "content": self.config.system_prompt})
179
+ messages.append({"role": "user", "content": prompt})
180
+
181
+ payload = {
182
+ "model": self.config.model,
183
+ "messages": messages,
184
+ "temperature": self.config.temperature,
185
+ }
186
+
187
+ async with httpx.AsyncClient(timeout=60) as client:
188
+ response = await client.post(
189
+ "https://api.openai.com/v1/chat/completions",
190
+ headers={
191
+ "Authorization": f"Bearer {api_key}",
192
+ "Content-Type": "application/json",
193
+ },
194
+ json=payload,
195
+ )
196
+
197
+ if response.status_code >= 400:
198
+ raise RuntimeError(
199
+ f"OpenAI supervisor request failed ({response.status_code}): {response.text}"
200
+ )
201
+
202
+ data = response.json()
203
+ choices = data.get("choices") or []
204
+ if not choices:
205
+ raise RuntimeError("OpenAI supervisor response contained no choices.")
206
+
207
+ message = choices[0].get("message") or {}
208
+ content = message.get("content")
209
+ if not content:
210
+ raise RuntimeError("OpenAI supervisor response missing content.")
211
+
212
+ return content
213
+
214
+ def _parse_decision(self, content: str) -> SupervisorDecision:
215
+ """Extract JSON payload from supervisor response and convert to decision."""
216
+
217
+ payload = self._extract_json(content)
218
+ decision_value = payload.get("decision")
219
+ if decision_value not in {"continue", "terminate"}:
220
+ raise ValueError(f"Supervisor returned invalid decision: {decision_value}")
221
+
222
+ decision: SupervisorDecisionType = decision_value # type: ignore[assignment]
223
+ result = SupervisorDecision(
224
+ decision=decision,
225
+ next_user_message=payload.get("next_user_message"),
226
+ termination_reason=payload.get("termination_reason"),
227
+ closing_user_message=payload.get("closing_user_message"),
228
+ raw_response=payload,
229
+ )
230
+
231
+ if decision == "continue":
232
+ if not (result.next_user_message and result.next_user_message.strip()):
233
+ raise ValueError("Supervisor decided to continue but provided no next_user_message.")
234
+ return result
235
+
236
+ @staticmethod
237
+ def _extract_json(content: str) -> Dict[str, Any]:
238
+ """Attempt to parse a JSON object from the model output."""
239
+
240
+ content = content.strip()
241
+ try:
242
+ return json.loads(content)
243
+ except json.JSONDecodeError:
244
+ pass
245
+
246
+ # Attempt to find JSON substring
247
+ match = re.search(r"\{.*\}", content, flags=re.DOTALL)
248
+ if match:
249
+ try:
250
+ return json.loads(match.group(0))
251
+ except json.JSONDecodeError:
252
+ pass
253
+
254
+ raise ValueError(f"Supervisor response was not valid JSON: {content}")
255
+
256
+
257
+ __all__ = ["ConversationSupervisor", "SupervisorDecision", "build_supervisor_prompt"]
258
+