korrel 0.1.0__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.
- korrel/__init__.py +42 -0
- korrel/adapter.py +38 -0
- korrel/cli.py +375 -0
- korrel/exporters/__init__.py +14 -0
- korrel/exporters/_shared.py +139 -0
- korrel/exporters/openenv.py +1079 -0
- korrel/exporters/verifiers.py +613 -0
- korrel/persona.py +80 -0
- korrel/providers.py +254 -0
- korrel/pytest_plugin.py +185 -0
- korrel/rubric.py +168 -0
- korrel/runtime.py +214 -0
- korrel/scenario.py +36 -0
- korrel/telemetry.py +347 -0
- korrel/tools.py +31 -0
- korrel/types.py +63 -0
- korrel-0.1.0.dist-info/METADATA +482 -0
- korrel-0.1.0.dist-info/RECORD +21 -0
- korrel-0.1.0.dist-info/WHEEL +4 -0
- korrel-0.1.0.dist-info/entry_points.txt +5 -0
- korrel-0.1.0.dist-info/licenses/LICENSE +21 -0
korrel/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""korrel: an OSS Python SDK for agent simulation.
|
|
2
|
+
|
|
3
|
+
Define a multi-turn agent test once as a code-first Scenario, run it against a
|
|
4
|
+
bring-your-own agent adapter, and score the transcript with a Rubric. Keys are
|
|
5
|
+
read from the environment at call time and stored nowhere.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .adapter import AgentAdapter, adapter_from_provider
|
|
9
|
+
from .persona import Persona
|
|
10
|
+
from .rubric import Rubric, RewardFn, make_judge
|
|
11
|
+
from .runtime import (
|
|
12
|
+
FailureCluster,
|
|
13
|
+
RunResult,
|
|
14
|
+
Transcript,
|
|
15
|
+
Turn,
|
|
16
|
+
run_scenario,
|
|
17
|
+
)
|
|
18
|
+
from .scenario import Scenario
|
|
19
|
+
from .tools import MockTool
|
|
20
|
+
from .types import Message, ToolCall, ToolFunction, ToolSchema
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Scenario",
|
|
24
|
+
"Persona",
|
|
25
|
+
"MockTool",
|
|
26
|
+
"Rubric",
|
|
27
|
+
"RewardFn",
|
|
28
|
+
"make_judge",
|
|
29
|
+
"run_scenario",
|
|
30
|
+
"RunResult",
|
|
31
|
+
"Transcript",
|
|
32
|
+
"Turn",
|
|
33
|
+
"FailureCluster",
|
|
34
|
+
"AgentAdapter",
|
|
35
|
+
"adapter_from_provider",
|
|
36
|
+
"Message",
|
|
37
|
+
"ToolCall",
|
|
38
|
+
"ToolFunction",
|
|
39
|
+
"ToolSchema",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
__version__ = "0.1.0"
|
korrel/adapter.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""AgentAdapter: the user-supplied agent under test.
|
|
2
|
+
|
|
3
|
+
An adapter is any callable that takes the conversation so far as canonical
|
|
4
|
+
``Message`` objects plus the available tool schemas, and returns the next
|
|
5
|
+
assistant ``Message`` (which may carry ``tool_calls``). The user wires their
|
|
6
|
+
own agent and their own keys here; Korrel holds none of them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
from .types import Message, ToolSchema
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .providers import Provider
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@runtime_checkable
|
|
20
|
+
class AgentAdapter(Protocol):
|
|
21
|
+
def __call__(
|
|
22
|
+
self, messages: list[Message], tools: list[ToolSchema]
|
|
23
|
+
) -> Message:
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def adapter_from_provider(provider: "Provider") -> AgentAdapter:
|
|
28
|
+
"""Wrap a Provider as an AgentAdapter.
|
|
29
|
+
|
|
30
|
+
Returns a callable that delegates to ``provider.complete(messages,
|
|
31
|
+
tools=tools)``. The caller supplies their own provider and their own
|
|
32
|
+
keys; Korrel holds none.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def _adapter(messages: list[Message], tools: list[ToolSchema]) -> Message:
|
|
36
|
+
return provider.complete(messages, tools=tools)
|
|
37
|
+
|
|
38
|
+
return _adapter # type: ignore[return-value]
|
korrel/cli.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""korrel CLI entry point.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
run <scenario.py> Run a scenario module and print the result.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import importlib.util
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
import types
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from .adapter import AgentAdapter
|
|
18
|
+
from .runtime import RunResult, run_scenario
|
|
19
|
+
from .scenario import Scenario
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Shared transcript writer (used by CLI and pytest plugin)
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _safe_filename_stem(scenario_id: str) -> str:
|
|
28
|
+
"""Reduce a scenario id to a single safe path component.
|
|
29
|
+
|
|
30
|
+
A scenario id is author-controlled. Used raw as a filename it could carry
|
|
31
|
+
path separators or an absolute path and escape ``out_dir`` (security review
|
|
32
|
+
K9). ``Path(...).name`` strips any directory part on both POSIX and Windows;
|
|
33
|
+
the fallback covers ids that reduce to nothing usable (``""``, ``.``, ``..``).
|
|
34
|
+
"""
|
|
35
|
+
stem = Path(scenario_id).name
|
|
36
|
+
if stem in ("", ".", ".."):
|
|
37
|
+
return "scenario"
|
|
38
|
+
return stem
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def write_transcript_for(result: RunResult, scenario_id: str, out_dir: Path) -> Path:
|
|
42
|
+
"""Write ``result.transcript`` as JSON and return the file path.
|
|
43
|
+
|
|
44
|
+
The file is ``<out_dir>/<scenario_id>.transcript.json``. ``scenario_id`` is
|
|
45
|
+
reduced to a single safe path component first. ``out_dir`` is created if it
|
|
46
|
+
does not exist.
|
|
47
|
+
"""
|
|
48
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
path = out_dir / f"{_safe_filename_stem(scenario_id)}.transcript.json"
|
|
50
|
+
path.write_text(result.transcript.model_dump_json(), encoding="utf-8")
|
|
51
|
+
return path
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Module loader (shared with pytest plugin)
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_module_from_path(file_path: Path) -> types.ModuleType:
|
|
60
|
+
"""Import a Python source file by path and return the module object."""
|
|
61
|
+
spec = importlib.util.spec_from_file_location(file_path.stem, file_path)
|
|
62
|
+
if spec is None or spec.loader is None:
|
|
63
|
+
raise ImportError(f"Cannot load module from {file_path}")
|
|
64
|
+
module = importlib.util.module_from_spec(spec)
|
|
65
|
+
spec.loader.exec_module(module) # type: ignore[union-attr]
|
|
66
|
+
return module
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_scenario_and_adapter(
|
|
70
|
+
module: types.ModuleType,
|
|
71
|
+
scenario_attr: str,
|
|
72
|
+
adapter_attr: str,
|
|
73
|
+
) -> tuple[Scenario, AgentAdapter]:
|
|
74
|
+
"""Extract a Scenario and AgentAdapter from a loaded module."""
|
|
75
|
+
scenario = getattr(module, scenario_attr, None)
|
|
76
|
+
if scenario is None:
|
|
77
|
+
raise AttributeError(
|
|
78
|
+
f"Module {module.__name__!r} has no attribute {scenario_attr!r}. "
|
|
79
|
+
"Define a module-level `scenario = Scenario(...)` in the file."
|
|
80
|
+
)
|
|
81
|
+
if not isinstance(scenario, Scenario):
|
|
82
|
+
raise TypeError(
|
|
83
|
+
f"{scenario_attr!r} in {module.__name__!r} is not a Scenario "
|
|
84
|
+
f"(got {type(scenario).__name__})."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
adapter = getattr(module, adapter_attr, None)
|
|
88
|
+
if adapter is None:
|
|
89
|
+
raise AttributeError(
|
|
90
|
+
f"Module {module.__name__!r} has no attribute {adapter_attr!r}. "
|
|
91
|
+
"Define a module-level `adapter = adapter_from_provider(...)` or "
|
|
92
|
+
"a custom callable in the file."
|
|
93
|
+
)
|
|
94
|
+
if not callable(adapter):
|
|
95
|
+
raise TypeError(
|
|
96
|
+
f"{adapter_attr!r} in {module.__name__!r} is not callable "
|
|
97
|
+
f"(got {type(adapter).__name__})."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return scenario, adapter # type: ignore[return-value]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load_scenario_only(
|
|
104
|
+
module: types.ModuleType,
|
|
105
|
+
scenario_attr: str,
|
|
106
|
+
) -> Scenario:
|
|
107
|
+
"""Extract a Scenario from a loaded module (no adapter required)."""
|
|
108
|
+
scenario = getattr(module, scenario_attr, None)
|
|
109
|
+
if scenario is None:
|
|
110
|
+
raise AttributeError(
|
|
111
|
+
f"Module {module.__name__!r} has no attribute {scenario_attr!r}. "
|
|
112
|
+
"Define a module-level `scenario = Scenario(...)` in the file."
|
|
113
|
+
)
|
|
114
|
+
if not isinstance(scenario, Scenario):
|
|
115
|
+
raise TypeError(
|
|
116
|
+
f"{scenario_attr!r} in {module.__name__!r} is not a Scenario "
|
|
117
|
+
f"(got {type(scenario).__name__})."
|
|
118
|
+
)
|
|
119
|
+
return scenario
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# `korrel export` command
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
_SUPPORTED_TARGETS = ("verifiers", "openenv")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _cmd_export(args: argparse.Namespace) -> int:
|
|
130
|
+
target: str = args.to
|
|
131
|
+
if target not in _SUPPORTED_TARGETS:
|
|
132
|
+
print(
|
|
133
|
+
f"error: unsupported export target {target!r}. "
|
|
134
|
+
f"Supported: {', '.join(_SUPPORTED_TARGETS)}.",
|
|
135
|
+
file=sys.stderr,
|
|
136
|
+
)
|
|
137
|
+
return 1
|
|
138
|
+
|
|
139
|
+
file_path = Path(args.file).resolve()
|
|
140
|
+
if not file_path.exists():
|
|
141
|
+
print(f"error: file not found: {file_path}", file=sys.stderr)
|
|
142
|
+
return 1
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
module = load_module_from_path(file_path)
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
print(f"error: could not load {file_path}: {exc}", file=sys.stderr)
|
|
148
|
+
return 1
|
|
149
|
+
|
|
150
|
+
if not args.scenario_attr.isidentifier():
|
|
151
|
+
print(
|
|
152
|
+
f"error: --scenario-attr must be a valid Python identifier, "
|
|
153
|
+
f"got {args.scenario_attr!r}.",
|
|
154
|
+
file=sys.stderr,
|
|
155
|
+
)
|
|
156
|
+
return 1
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
scenario = load_scenario_only(module, scenario_attr=args.scenario_attr)
|
|
160
|
+
except (AttributeError, TypeError) as exc:
|
|
161
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
162
|
+
return 1
|
|
163
|
+
|
|
164
|
+
# scenario.id is author-controlled; reduce it to a single safe path component
|
|
165
|
+
# before using it as the default output directory name (security review K9:
|
|
166
|
+
# an id carrying separators or an absolute path could otherwise escape
|
|
167
|
+
# .korrel/export/). When --out is given the operator owns that path.
|
|
168
|
+
out_dir = (
|
|
169
|
+
Path(args.out)
|
|
170
|
+
if args.out
|
|
171
|
+
else Path(".korrel") / "export" / _safe_filename_stem(scenario.id)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if target == "verifiers":
|
|
175
|
+
# Import the artifact emitter (does not import verifiers itself).
|
|
176
|
+
from .exporters.verifiers import write_verifiers_env
|
|
177
|
+
|
|
178
|
+
produced = write_verifiers_env(
|
|
179
|
+
scenario,
|
|
180
|
+
out_dir,
|
|
181
|
+
scenario_source_path=file_path,
|
|
182
|
+
scenario_attr=args.scenario_attr,
|
|
183
|
+
)
|
|
184
|
+
pyproject = produced / "pyproject.toml"
|
|
185
|
+
env_module = _safe_filename_stem(scenario.id).replace("-", "_").replace(" ", "_")
|
|
186
|
+
env_module_py = produced / f"{env_module}.py"
|
|
187
|
+
scenario_py = produced / "_scenario.py"
|
|
188
|
+
print(f"{'target':<12}: verifiers")
|
|
189
|
+
print(f"{'scenario':<12}: {scenario.id}")
|
|
190
|
+
print(f"{'out_dir':<12}: {produced}")
|
|
191
|
+
print(f"{'pyproject':<12}: {pyproject}")
|
|
192
|
+
print(f"{'env_module':<12}: {env_module_py}")
|
|
193
|
+
print(f"{'scenario_src':<12}: {scenario_py}")
|
|
194
|
+
|
|
195
|
+
elif target == "openenv":
|
|
196
|
+
# Import the artifact emitter (does not import openenv-core itself).
|
|
197
|
+
from .exporters.openenv import write_openenv_env
|
|
198
|
+
|
|
199
|
+
produced = write_openenv_env(
|
|
200
|
+
scenario,
|
|
201
|
+
out_dir,
|
|
202
|
+
scenario_source_path=file_path,
|
|
203
|
+
scenario_attr=args.scenario_attr,
|
|
204
|
+
)
|
|
205
|
+
env_name = _safe_filename_stem(scenario.id).replace("_", "-")
|
|
206
|
+
env_module = env_name.replace("-", "_")
|
|
207
|
+
pyproject = produced / "pyproject.toml"
|
|
208
|
+
models_py = produced / "models.py"
|
|
209
|
+
env_py = produced / "server" / f"{env_module}_environment.py"
|
|
210
|
+
scenario_py = produced / "_scenario.py"
|
|
211
|
+
print(f"{'target':<12}: openenv")
|
|
212
|
+
print(f"{'scenario':<12}: {scenario.id}")
|
|
213
|
+
print(f"{'out_dir':<12}: {produced}")
|
|
214
|
+
print(f"{'pyproject':<12}: {pyproject}")
|
|
215
|
+
print(f"{'models':<12}: {models_py}")
|
|
216
|
+
print(f"{'environment':<12}: {env_py}")
|
|
217
|
+
print(f"{'scenario_src':<12}: {scenario_py}")
|
|
218
|
+
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
# `korrel run` command
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
228
|
+
file_path = Path(args.file).resolve()
|
|
229
|
+
if not file_path.exists():
|
|
230
|
+
print(f"error: file not found: {file_path}", file=sys.stderr)
|
|
231
|
+
return 1
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
module = load_module_from_path(file_path)
|
|
235
|
+
except Exception as exc:
|
|
236
|
+
print(f"error: could not load {file_path}: {exc}", file=sys.stderr)
|
|
237
|
+
return 1
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
scenario, adapter = load_scenario_and_adapter(
|
|
241
|
+
module,
|
|
242
|
+
scenario_attr=args.scenario_attr,
|
|
243
|
+
adapter_attr=args.adapter_attr,
|
|
244
|
+
)
|
|
245
|
+
except (AttributeError, TypeError) as exc:
|
|
246
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
247
|
+
return 1
|
|
248
|
+
|
|
249
|
+
seed: Optional[int] = args.seed
|
|
250
|
+
t_start = time.monotonic()
|
|
251
|
+
result = run_scenario(scenario, adapter, seed=seed)
|
|
252
|
+
duration_s = time.monotonic() - t_start
|
|
253
|
+
|
|
254
|
+
out_dir = Path(args.out) if args.out else Path(".korrel")
|
|
255
|
+
transcript_path = write_transcript_for(result, scenario.id, out_dir)
|
|
256
|
+
|
|
257
|
+
# Print result summary. The "model calls" label is wider than the prior
|
|
258
|
+
# 10-char pad, so the pad is widened to 12 across every line to stay aligned.
|
|
259
|
+
status = "pass" if result.passed else "fail"
|
|
260
|
+
print(f"{'scenario':<12}: {scenario.id}")
|
|
261
|
+
print(f"{'score':<12}: {result.score:.4f}")
|
|
262
|
+
print(f"{'status':<12}: {status}")
|
|
263
|
+
print(f"{'model calls':<12}: {result.model_calls}")
|
|
264
|
+
if result.failed_functions:
|
|
265
|
+
print(f"{'failed':<12}: {', '.join(result.failed_functions)}")
|
|
266
|
+
if result.clusters:
|
|
267
|
+
cluster_strs = [f"{c.function}({c.signature})" for c in result.clusters]
|
|
268
|
+
print(f"{'clusters':<12}: {', '.join(cluster_strs)}")
|
|
269
|
+
print(f"{'transcript':<12}: {transcript_path}")
|
|
270
|
+
|
|
271
|
+
# Emit telemetry. Best-effort: errors are swallowed inside emit_run.
|
|
272
|
+
from .telemetry import emit_run
|
|
273
|
+
|
|
274
|
+
total_turns = len(result.transcript.turns)
|
|
275
|
+
emit_run(
|
|
276
|
+
scenario_count=1,
|
|
277
|
+
total_turns=total_turns,
|
|
278
|
+
pass_count=1 if result.passed else 0,
|
|
279
|
+
fail_count=0 if result.passed else 1,
|
|
280
|
+
duration_s=duration_s,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return 0 if result.passed else 1
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
# Entry point
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
292
|
+
parser = argparse.ArgumentParser(
|
|
293
|
+
prog="korrel",
|
|
294
|
+
description="korrel: agent simulation SDK.",
|
|
295
|
+
)
|
|
296
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
297
|
+
|
|
298
|
+
run_parser = sub.add_parser("run", help="Run a scenario module.")
|
|
299
|
+
run_parser.add_argument("file", metavar="SCENARIO_PY", help="Path to the scenario module.")
|
|
300
|
+
run_parser.add_argument(
|
|
301
|
+
"--out",
|
|
302
|
+
metavar="DIR",
|
|
303
|
+
default=None,
|
|
304
|
+
help="Directory for transcript output (default: .korrel/).",
|
|
305
|
+
)
|
|
306
|
+
run_parser.add_argument(
|
|
307
|
+
"--seed",
|
|
308
|
+
metavar="N",
|
|
309
|
+
type=int,
|
|
310
|
+
default=None,
|
|
311
|
+
help="Override the scenario seed.",
|
|
312
|
+
)
|
|
313
|
+
run_parser.add_argument(
|
|
314
|
+
"--scenario-attr",
|
|
315
|
+
metavar="NAME",
|
|
316
|
+
default="scenario",
|
|
317
|
+
help="Module attribute name for the Scenario (default: scenario).",
|
|
318
|
+
)
|
|
319
|
+
run_parser.add_argument(
|
|
320
|
+
"--adapter-attr",
|
|
321
|
+
metavar="NAME",
|
|
322
|
+
default="adapter",
|
|
323
|
+
help="Module attribute name for the AgentAdapter (default: adapter).",
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
export_parser = sub.add_parser(
|
|
327
|
+
"export",
|
|
328
|
+
help="Export a scenario as an RL training environment.",
|
|
329
|
+
)
|
|
330
|
+
export_parser.add_argument(
|
|
331
|
+
"file",
|
|
332
|
+
metavar="SCENARIO_PY",
|
|
333
|
+
help="Path to the scenario module.",
|
|
334
|
+
)
|
|
335
|
+
export_parser.add_argument(
|
|
336
|
+
"--to",
|
|
337
|
+
metavar="TARGET",
|
|
338
|
+
required=True,
|
|
339
|
+
help=f"Export target. Supported: {', '.join(_SUPPORTED_TARGETS)}.",
|
|
340
|
+
)
|
|
341
|
+
export_parser.add_argument(
|
|
342
|
+
"--out",
|
|
343
|
+
metavar="DIR",
|
|
344
|
+
default=None,
|
|
345
|
+
help="Output directory for the generated package (default: .korrel/export/<scenario-id>/).",
|
|
346
|
+
)
|
|
347
|
+
export_parser.add_argument(
|
|
348
|
+
"--scenario-attr",
|
|
349
|
+
metavar="NAME",
|
|
350
|
+
default="scenario",
|
|
351
|
+
help="Module attribute name for the Scenario (default: scenario).",
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
return parser
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def main(argv: Optional[list[str]] = None) -> None:
|
|
358
|
+
parser = _build_parser()
|
|
359
|
+
args = parser.parse_args(argv)
|
|
360
|
+
|
|
361
|
+
if args.command is None:
|
|
362
|
+
parser.print_help()
|
|
363
|
+
sys.exit(0)
|
|
364
|
+
|
|
365
|
+
if args.command == "run":
|
|
366
|
+
sys.exit(_cmd_run(args))
|
|
367
|
+
elif args.command == "export":
|
|
368
|
+
sys.exit(_cmd_export(args))
|
|
369
|
+
else:
|
|
370
|
+
parser.print_help()
|
|
371
|
+
sys.exit(1)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
if __name__ == "__main__":
|
|
375
|
+
main()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""korrel.exporters: optional export targets for Korrel scenarios.
|
|
2
|
+
|
|
3
|
+
Each submodule implements an exporter for a specific RL training framework.
|
|
4
|
+
None of them import the target framework at module load; imports are deferred
|
|
5
|
+
to function call time so that ``import korrel`` works without the target
|
|
6
|
+
framework installed.
|
|
7
|
+
|
|
8
|
+
Available submodules:
|
|
9
|
+
|
|
10
|
+
- ``korrel.exporters.verifiers``: export a Scenario as a verifiers
|
|
11
|
+
Environment (requires the ``verifiers`` optional extra, Python <3.14).
|
|
12
|
+
- ``korrel.exporters.openenv``: export a Scenario as an OpenEnv environment
|
|
13
|
+
package (requires the ``openenv`` optional extra; openenv-core>=0.3.0).
|
|
14
|
+
"""
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""korrel.exporters._shared: conversion helpers shared across exporters.
|
|
2
|
+
|
|
3
|
+
These helpers convert between the canonical Korrel message types and the flat
|
|
4
|
+
message shapes used by downstream frameworks (verifiers, OpenEnv). None of them
|
|
5
|
+
import a framework at definition time.
|
|
6
|
+
|
|
7
|
+
Confirmed against: korrel canonical types as of v0.2 (src/korrel/types.py).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _field(obj: Any, key: str, default: Any = None) -> Any:
|
|
17
|
+
"""Read a field from a message or tool call object.
|
|
18
|
+
|
|
19
|
+
The value may arrive as a pydantic object attribute or a plain dict entry.
|
|
20
|
+
Unlike ``getattr(...) or dict.get(...)``, this does not treat a falsy-but-
|
|
21
|
+
present value (an empty string content) as missing: only ``None`` or a
|
|
22
|
+
truly absent key falls back to ``default``.
|
|
23
|
+
"""
|
|
24
|
+
if isinstance(obj, dict):
|
|
25
|
+
return obj.get(key, default)
|
|
26
|
+
value = getattr(obj, key, default)
|
|
27
|
+
return default if value is None else value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _content_to_str(content: Any) -> str:
|
|
31
|
+
"""Narrow MessageContent (str | list[ContentPart]) to str.
|
|
32
|
+
|
|
33
|
+
Content may be a list of content parts (text, image, audio). Korrel
|
|
34
|
+
canonical content is Optional[str]. String content passes through; list
|
|
35
|
+
content is serialized as JSON per the spec (lossy edge: content-shape
|
|
36
|
+
narrowing documented in the mapping spec).
|
|
37
|
+
"""
|
|
38
|
+
if content is None:
|
|
39
|
+
return ""
|
|
40
|
+
if isinstance(content, str):
|
|
41
|
+
return content
|
|
42
|
+
# List of content parts: join text parts, serialize non-text as JSON.
|
|
43
|
+
parts: list[str] = []
|
|
44
|
+
for part in content:
|
|
45
|
+
if isinstance(part, dict):
|
|
46
|
+
if part.get("type") == "text":
|
|
47
|
+
parts.append(part.get("text", ""))
|
|
48
|
+
else:
|
|
49
|
+
parts.append(json.dumps(part))
|
|
50
|
+
elif hasattr(part, "type"):
|
|
51
|
+
if getattr(part, "type", None) == "text":
|
|
52
|
+
parts.append(getattr(part, "text", ""))
|
|
53
|
+
else:
|
|
54
|
+
parts.append(
|
|
55
|
+
json.dumps(
|
|
56
|
+
part.model_dump() if hasattr(part, "model_dump") else str(part)
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
else:
|
|
60
|
+
parts.append(str(part))
|
|
61
|
+
return "".join(parts)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _to_korrel_messages(messages: list[Any]) -> list[Any]:
|
|
65
|
+
"""Convert a list of flat messages to korrel canonical Messages.
|
|
66
|
+
|
|
67
|
+
Field-by-field per the mapping spec (Section: Reward-function value shim):
|
|
68
|
+
- system/user/assistant without tool calls: content narrowed to str.
|
|
69
|
+
- assistant with tool calls: FLAT ToolCall{id,name,arguments} ->
|
|
70
|
+
NESTED korrel.ToolCall{id,type:"function",function:{name,arguments}}.
|
|
71
|
+
- tool result: ToolMessage{role:"tool",tool_call_id,content}.
|
|
72
|
+
``arguments`` stays a JSON string on both sides.
|
|
73
|
+
"""
|
|
74
|
+
from ..types import Message, ToolCall, ToolFunction
|
|
75
|
+
|
|
76
|
+
result: list[Message] = []
|
|
77
|
+
for msg in messages:
|
|
78
|
+
role = _field(msg, "role")
|
|
79
|
+
if role is None:
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
if role == "system":
|
|
83
|
+
result.append(
|
|
84
|
+
Message(role="system", content=_content_to_str(_field(msg, "content")))
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
elif role == "user":
|
|
88
|
+
result.append(
|
|
89
|
+
Message(role="user", content=_content_to_str(_field(msg, "content")))
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
elif role == "assistant":
|
|
93
|
+
content = _field(msg, "content")
|
|
94
|
+
raw_tool_calls = _field(msg, "tool_calls")
|
|
95
|
+
korrel_tool_calls: Optional[list[ToolCall]] = None
|
|
96
|
+
if raw_tool_calls:
|
|
97
|
+
korrel_tool_calls = []
|
|
98
|
+
for tc in raw_tool_calls:
|
|
99
|
+
tc_id = _field(tc, "id", "") or ""
|
|
100
|
+
tc_name = _field(tc, "name", "") or ""
|
|
101
|
+
tc_args = _field(tc, "arguments", "{}") or "{}"
|
|
102
|
+
korrel_tool_calls.append(
|
|
103
|
+
ToolCall(
|
|
104
|
+
id=tc_id,
|
|
105
|
+
type="function",
|
|
106
|
+
function=ToolFunction(name=tc_name, arguments=tc_args),
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
result.append(
|
|
110
|
+
Message(
|
|
111
|
+
role="assistant",
|
|
112
|
+
content=_content_to_str(content) if content is not None else None,
|
|
113
|
+
tool_calls=korrel_tool_calls or None,
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
elif role == "tool":
|
|
118
|
+
result.append(
|
|
119
|
+
Message(
|
|
120
|
+
role="tool",
|
|
121
|
+
content=_content_to_str(_field(msg, "content")),
|
|
122
|
+
tool_call_id=_field(msg, "tool_call_id"),
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _sanitize_id_for_comment(scenario_id: str) -> str:
|
|
130
|
+
"""Return a display-safe single-line label for use in a comment or header.
|
|
131
|
+
|
|
132
|
+
Strips leading/trailing whitespace, collapses newlines to a space, and
|
|
133
|
+
removes every occurrence of triple-double-quote so the label cannot close
|
|
134
|
+
or escape from any surrounding string region in the generated module.
|
|
135
|
+
The exact scenario id is always preserved separately via repr().
|
|
136
|
+
"""
|
|
137
|
+
label = scenario_id.strip().replace("\n", " ").replace("\r", " ")
|
|
138
|
+
label = label.replace('"""', "")
|
|
139
|
+
return label
|