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.
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/PKG-INFO +13 -1
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/README.md +12 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/__init__.py +1 -1
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/arg_binder.py +102 -22
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/commands/evaluate.py +16 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/commands/init.py +8 -4
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/commands/run.py +86 -0
- fluxloop_cli-0.2.22/fluxloop_cli/conversation_supervisor.py +258 -0
- fluxloop_cli-0.2.22/fluxloop_cli/environment.py +71 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/evaluation/config.py +15 -1
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/evaluation/engine/reporting/html.py +14 -14
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/evaluation/llm.py +34 -0
- fluxloop_cli-0.2.22/fluxloop_cli/evaluation/prompts/__init__.py +53 -0
- fluxloop_cli-0.2.22/fluxloop_cli/evaluation/prompts/base.py +48 -0
- fluxloop_cli-0.2.22/fluxloop_cli/evaluation/prompts/information_completeness.py +45 -0
- fluxloop_cli-0.2.22/fluxloop_cli/evaluation/prompts/intent_recognition.py +46 -0
- fluxloop_cli-0.2.22/fluxloop_cli/evaluation/prompts/response_clarity.py +44 -0
- fluxloop_cli-0.2.22/fluxloop_cli/evaluation/prompts/response_consistency.py +46 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/runner.py +231 -14
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/templates.py +31 -6
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli.egg-info/PKG-INFO +13 -1
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli.egg-info/SOURCES.txt +8 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/pyproject.toml +1 -1
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/tests/test_arg_binder.py +48 -1
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/tests/test_evaluate_command.py +48 -0
- fluxloop_cli-0.2.22/tests/test_run_command.py +72 -0
- fluxloop_cli-0.2.20/fluxloop_cli/evaluation/prompts/__init__.py +0 -182
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/commands/__init__.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/commands/config.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/commands/doctor.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/commands/generate.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/commands/parse.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/commands/record.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/commands/status.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/config_loader.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/config_schema.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/constants.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/evaluation/__init__.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/evaluation/engine/__init__.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/evaluation/engine/analysis.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/evaluation/engine/core.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/evaluation/engine/reporting/__init__.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/evaluation/engine/reporting/markdown.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/evaluation/engine/success.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/evaluation/rules.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/input_generator.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/llm_generator.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/main.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/project_paths.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/target_loader.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli/validators.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli.egg-info/dependency_links.txt +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli.egg-info/entry_points.txt +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli.egg-info/requires.txt +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/fluxloop_cli.egg-info/top_level.txt +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/setup.cfg +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/tests/test_analysis_recommendations.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/tests/test_config_command.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/tests/test_evaluation_llm.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/tests/test_input_generator.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/tests/test_prompt_library.py +0 -0
- {fluxloop_cli-0.2.20 → fluxloop_cli-0.2.22}/tests/test_success_criteria.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
144
|
-
|
|
225
|
+
if iteration is not None:
|
|
226
|
+
assign(iteration, ["iteration", "run_iteration", "loop_index"])
|
|
145
227
|
|
|
146
|
-
|
|
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.
|
|
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.
|
|
184
|
+
console.print("3. Customize the sample agent in [cyan]examples/simple_agent.py[/cyan]")
|
|
185
185
|
else:
|
|
186
|
-
console.print("3.
|
|
187
|
-
|
|
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
|
+
|