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 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