lisa-harness-e2e 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.
e2etest/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """End-to-end scenario harness for Lisa packs (drives a full ``HostRuntime``)."""
e2etest/bundle.py ADDED
@@ -0,0 +1,67 @@
1
+ """Write a run bundle: verdict, raw outcome, and a readable transcript."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from e2etest.oracles import Evidence, OracleResult
11
+ from e2etest.scenario import Scenario
12
+
13
+ _TEXT_LIMIT = 4000
14
+
15
+
16
+ def verdict(scenario: Scenario, evidence: Evidence, results: list[OracleResult]) -> dict[str, Any]:
17
+ failed = [r for r in results if not r.passed]
18
+ return {
19
+ "scenario": scenario.scenario_id,
20
+ "scenario_file": str(scenario.source),
21
+ "run_dir": str(evidence.paths.run_dir),
22
+ "passed": not failed,
23
+ "first_failure": asdict(failed[0]) if failed else None,
24
+ "exchanges": len(evidence.outcome.exchanges),
25
+ "aborted": evidence.outcome.aborted,
26
+ "results": [asdict(r) for r in results],
27
+ }
28
+
29
+
30
+ def _transcript(evidence: Evidence) -> str:
31
+ lines = ["# Transcript", ""]
32
+ for ex in evidence.outcome.exchanges:
33
+ lines.append(f"## #{ex.index} {ex.kind} ({ex.duration_s}s)")
34
+ lines += ["", "```text", ex.message, "```", ""]
35
+ flags = []
36
+ if ex.approval:
37
+ flags.append(
38
+ f"approval: {ex.approval.get('target_id')} ({ex.approval.get('request_id')})"
39
+ )
40
+ if ex.operator_exit:
41
+ flags.append(f"operator_exit: {ex.operator_exit}")
42
+ if ex.yielded_question:
43
+ flags.append(f"yielded: {ex.yielded_question}")
44
+ flags += [f"error: {e}" for e in ex.errors]
45
+ lines += [f"- {f}" for f in flags]
46
+ text = ex.text if len(ex.text) <= _TEXT_LIMIT else ex.text[:_TEXT_LIMIT] + "\n…[truncated]"
47
+ lines += ["", text, ""]
48
+ lines += ["# Thread entries", ""]
49
+ for entry in evidence.thread_entries:
50
+ receipts = [r.get("capability_id") for r in entry.get("effect_receipts") or ()]
51
+ provenance = entry.get("provenance") or {}
52
+ lines.append(
53
+ f"- turn {entry.get('turn')} mode={entry.get('mode_id')} "
54
+ f"handoff={entry.get('handoff_to') or '-'} model={provenance.get('model_id', '-')} "
55
+ f"exit={provenance.get('operator_exit') or '-'} receipts={receipts}"
56
+ )
57
+ return "\n".join(lines) + "\n"
58
+
59
+
60
+ def write_bundle(run_dir: Path, payload: dict[str, Any], evidence: Evidence) -> Path:
61
+ (run_dir / "outcome.json").write_text(
62
+ json.dumps(evidence.outcome.to_json(), indent=1), encoding="utf-8"
63
+ )
64
+ (run_dir / "transcript.md").write_text(_transcript(evidence), encoding="utf-8")
65
+ path = run_dir / "verdict.json"
66
+ path.write_text(json.dumps(payload, indent=1), encoding="utf-8")
67
+ return path
e2etest/cli.py ADDED
@@ -0,0 +1,63 @@
1
+ """``lisa-e2e``: run one scenario end to end and write a verdict bundle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+
10
+ from e2etest.bundle import verdict, write_bundle
11
+ from e2etest.driver import run_conversation
12
+ from e2etest.environment import prepare_run
13
+ from e2etest.oracles import Evidence, evaluate
14
+ from e2etest.packs_venv import ensure_packs_venv
15
+ from e2etest.scenario import load_scenario
16
+
17
+ _REPO_ROOT = Path(__file__).resolve().parents[3]
18
+ _EXIT_FAIL = 1
19
+ _EXIT_SETUP = 2
20
+
21
+
22
+ def _parser() -> argparse.ArgumentParser:
23
+ parser = argparse.ArgumentParser(prog="lisa-e2e", description=__doc__)
24
+ sub = parser.add_subparsers(dest="command", required=True)
25
+ run = sub.add_parser("run", help="Run a scenario against a live model.")
26
+ run.add_argument("scenario", type=Path)
27
+ run.add_argument("--runs-dir", type=Path, default=_REPO_ROOT / ".e2e" / "runs")
28
+ run.add_argument("--rebuild-venv", action="store_true")
29
+ return parser
30
+
31
+
32
+ def _run(args: argparse.Namespace) -> int:
33
+ scenario = load_scenario(args.scenario)
34
+ venv = ensure_packs_venv(
35
+ scenario.packs, _REPO_ROOT / ".e2e" / "cache", rebuild=args.rebuild_venv
36
+ )
37
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
38
+ run_dir = (args.runs_dir / scenario.scenario_id.replace("/", "__") / stamp).resolve()
39
+ paths = prepare_run(scenario, run_dir, venv)
40
+ print(f"run dir: {run_dir}", flush=True)
41
+ outcome = run_conversation(scenario, paths, venv)
42
+ evidence = Evidence(paths, outcome)
43
+ payload = verdict(scenario, evidence, evaluate(scenario.oracles, evidence))
44
+ path = write_bundle(run_dir, payload, evidence)
45
+ for result in payload["results"]:
46
+ mark = "PASS" if result["passed"] else "FAIL"
47
+ first = result["detail"].splitlines()[0] if result["detail"] else ""
48
+ print(f"{mark} {result['phase']}.{result['name']}: {first}")
49
+ print(f"verdict: {'PASS' if payload['passed'] else 'FAIL'} → {path}")
50
+ return 0 if payload["passed"] else _EXIT_FAIL
51
+
52
+
53
+ def main(argv: list[str] | None = None) -> int:
54
+ args = _parser().parse_args(argv)
55
+ try:
56
+ return _run(args)
57
+ except (OSError, ValueError, RuntimeError) as exc:
58
+ print(f"setup error: {type(exc).__name__}: {exc}", file=sys.stderr)
59
+ return _EXIT_SETUP
60
+
61
+
62
+ if __name__ == "__main__":
63
+ sys.exit(main())
e2etest/driver.py ADDED
@@ -0,0 +1,249 @@
1
+ """Drive a real ``HostRuntime`` through a scenario's conversation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import time
8
+ from collections.abc import Iterator
9
+ from contextlib import contextmanager
10
+ from dataclasses import asdict, dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import runtime.turn_hooks_builder
15
+ from runtime.bootstrap import build_runtime
16
+ from runtime.handle_message import handle_message
17
+ from runtime.paths import inject_packs_venv_site_packages
18
+ from runtime.types import HostCommandResult
19
+
20
+ from e2etest.environment import RunPaths
21
+ from e2etest.scenario import Scenario
22
+ from e2etest.scripted import ScriptedGatewayProvider, load_script
23
+
24
+ _APPROVAL_SCHEMA = "lisa.approval.v1"
25
+
26
+
27
+ @dataclass
28
+ class Exchange:
29
+ """One message sent to the host and what came back."""
30
+
31
+ index: int
32
+ kind: str
33
+ message: str
34
+ text: str = ""
35
+ is_error: bool = False
36
+ operator_exit: str = ""
37
+ yielded_question: str = ""
38
+ approval: dict[str, Any] | None = None
39
+ errors: list[str] = field(default_factory=list)
40
+ duration_s: float = 0.0
41
+
42
+
43
+ @dataclass
44
+ class DriverOutcome:
45
+ """Everything the driver observed; ``aborted`` names a blown budget."""
46
+
47
+ exchanges: list[Exchange] = field(default_factory=list)
48
+ stages: list[dict[str, Any]] = field(default_factory=list)
49
+ aborted: str = ""
50
+ model_calls: list[dict[str, Any]] = field(default_factory=list)
51
+
52
+ def to_json(self) -> dict[str, Any]:
53
+ return {
54
+ "aborted": self.aborted,
55
+ "exchanges": [asdict(e) for e in self.exchanges],
56
+ "stages": self.stages,
57
+ "model_calls": self.model_calls,
58
+ }
59
+
60
+
61
+ class RecordingTransport:
62
+ """``TransportCallbacks`` that records stages and errors instead of rendering."""
63
+
64
+ def __init__(self, outcome: DriverOutcome) -> None:
65
+ self._outcome = outcome
66
+ self.errors: list[str] = []
67
+
68
+ async def on_stage(self, label: str, data: dict[str, Any] | None = None) -> None:
69
+ self._outcome.stages.append(
70
+ {
71
+ "exchange": len(self._outcome.exchanges),
72
+ "t": round(time.monotonic(), 3),
73
+ "label": label,
74
+ "data": _jsonable(data or {}),
75
+ }
76
+ )
77
+
78
+ async def on_text_chunk(self, text: str, reasoning: str = "") -> None:
79
+ return None
80
+
81
+ async def on_response(self, text: str, data: dict[str, Any] | None = None) -> None:
82
+ return None
83
+
84
+ async def on_debug_context(self, context: dict[str, Any]) -> None:
85
+ return None
86
+
87
+ async def on_error(self, message: str, exc: Exception | None = None) -> None:
88
+ self.errors.append(message)
89
+
90
+
91
+ def _jsonable(value: Any) -> Any:
92
+ if isinstance(value, dict):
93
+ return {str(k): _jsonable(v) for k, v in value.items()}
94
+ if isinstance(value, list | tuple):
95
+ return [_jsonable(v) for v in value]
96
+ if value is None or isinstance(value, str | int | float | bool):
97
+ return value
98
+ return str(value)
99
+
100
+
101
+ def approval_payload(result: HostCommandResult) -> dict[str, Any] | None:
102
+ """Return the structured approval request a turn paused on, if any."""
103
+ data = result.data or {}
104
+ nested = data.get("approval")
105
+ if isinstance(nested, dict) and nested.get("request_id"):
106
+ return nested
107
+ if data.get("schema") == _APPROVAL_SCHEMA and data.get("request_id"):
108
+ return data
109
+ return None
110
+
111
+
112
+ class _Conversation:
113
+ def __init__(self, runtime: Any, scenario: Scenario, outcome: DriverOutcome) -> None:
114
+ self.runtime = runtime
115
+ self.scenario = scenario
116
+ self.outcome = outcome
117
+ self.transport = RecordingTransport(outcome)
118
+ self.decisions = 0
119
+ self.continues = 0
120
+ self.yield_replies = 0
121
+
122
+ async def send(self, kind: str, message: str) -> Exchange:
123
+ if len(self.outcome.exchanges) >= self.scenario.budgets.max_messages:
124
+ raise _BudgetExceededError(f"max_messages={self.scenario.budgets.max_messages}")
125
+ message = self._expand_message(message)
126
+ exchange = Exchange(index=len(self.outcome.exchanges), kind=kind, message=message)
127
+ self.outcome.exchanges.append(exchange)
128
+ self.transport.errors = []
129
+ started = time.monotonic()
130
+ result = await handle_message(self.runtime, message, self.transport)
131
+ exchange.duration_s = round(time.monotonic() - started, 2)
132
+ exchange.text = result.text
133
+ exchange.is_error = result.is_error
134
+ exchange.operator_exit = result.operator_exit
135
+ exchange.yielded_question = result.yielded_question
136
+ exchange.approval = approval_payload(result)
137
+ exchange.errors = list(self.transport.errors)
138
+ return exchange
139
+
140
+ def _expand_message(self, message: str) -> str:
141
+ """Resolve ``$RECOVERY_OPEN_ID`` from the run's recovery store.
142
+
143
+ Substitutes the work-item id of the first open (running/stalled/
144
+ indeterminate) recovery record, read straight from
145
+ ``data/state/recovery-items.json``. Left verbatim when there is no
146
+ open record (the substitution happens at send time, so a stall
147
+ created by an earlier turn resolves here). Lets scenarios drive
148
+ deterministic operator remediation by id without parsing model
149
+ prose.
150
+ """
151
+ if "$RECOVERY_OPEN_ID" not in message:
152
+ return message
153
+ path = self.runtime.paths.data_dir / "state" / "recovery-items.json"
154
+ if not path.is_file():
155
+ return message
156
+ try:
157
+ records = json.loads(path.read_text(encoding="utf-8"))
158
+ except (OSError, ValueError):
159
+ return message
160
+ if not isinstance(records, dict):
161
+ return message
162
+ for record in records.values():
163
+ if record.get("state") in ("running", "stalled", "indeterminate"):
164
+ return message.replace("$RECOVERY_OPEN_ID", str(record.get("work_item_id") or ""))
165
+ return message
166
+
167
+ async def follow_up(self, exchange: Exchange) -> Exchange | None:
168
+ """Answer whatever left the turn unfinished, or return ``None`` when it finished."""
169
+ budgets = self.scenario.budgets
170
+ if exchange.approval:
171
+ if self.decisions >= self.scenario.approvals.max_decisions:
172
+ raise _BudgetExceededError("approvals.max_decisions")
173
+ self.decisions += 1
174
+ verdict = self.scenario.approvals.decide(str(exchange.approval.get("target_id", "")))
175
+ return await self.send(verdict, f"/{verdict} {exchange.approval['request_id']}")
176
+ if exchange.yielded_question and self.yield_replies < budgets.max_yield_replies:
177
+ self.yield_replies += 1
178
+ return await self.send("yield_reply", self.scenario.yield_reply)
179
+ if exchange.operator_exit and self.continues < budgets.max_continues:
180
+ self.continues += 1
181
+ return await self.send("continue", self.scenario.continue_message)
182
+ return None
183
+
184
+ async def run(self) -> None:
185
+ for turn in self.scenario.turns:
186
+ exchange: Exchange | None = await self.send("turn", turn)
187
+ while exchange is not None:
188
+ exchange = await self.follow_up(exchange)
189
+
190
+
191
+ class _BudgetExceededError(Exception):
192
+ pass
193
+
194
+
195
+ async def _drive(runtime: Any, scenario: Scenario, outcome: DriverOutcome) -> None:
196
+ conversation = _Conversation(runtime, scenario, outcome)
197
+ await runtime.start()
198
+ try:
199
+ async with asyncio.timeout(scenario.budgets.wall_clock_s):
200
+ await conversation.run()
201
+ except _BudgetExceededError as exc:
202
+ outcome.aborted = f"budget exceeded: {exc}"
203
+ except TimeoutError:
204
+ outcome.aborted = f"budget exceeded: wall_clock_s={scenario.budgets.wall_clock_s}"
205
+ finally:
206
+ try:
207
+ await asyncio.wait_for(runtime.aclose(), timeout=30)
208
+ except TimeoutError:
209
+ outcome.aborted = outcome.aborted or "runtime.aclose() timed out"
210
+
211
+
212
+ @contextmanager
213
+ def _gateway_override(scripted: ScriptedGatewayProvider | None) -> Iterator[None]:
214
+ """Bind every provider gateway to the model script while the runtime is wired."""
215
+ if scripted is None:
216
+ yield
217
+ return
218
+ hooks = runtime.turn_hooks_builder
219
+ original = hooks._resolve_gateway_provider_for_runtime
220
+ hooks._resolve_gateway_provider_for_runtime = lambda _runtime: scripted
221
+ try:
222
+ yield
223
+ finally:
224
+ hooks._resolve_gateway_provider_for_runtime = original
225
+
226
+
227
+ def run_conversation(scenario: Scenario, paths: RunPaths, packs_venv: Path) -> DriverOutcome:
228
+ """Boot a runtime against ``paths`` and play the scenario's turns."""
229
+ outcome = DriverOutcome()
230
+ if inject_packs_venv_site_packages(packs_venv) is None:
231
+ outcome.aborted = f"packs venv has no site-packages: {packs_venv}"
232
+ return outcome
233
+ scripted: ScriptedGatewayProvider | None = None
234
+ if scenario.model_script is not None:
235
+ scripted = load_script(scenario.model_script)
236
+ outcome.model_calls = scripted.log
237
+ with _gateway_override(scripted):
238
+ runtime = build_runtime(
239
+ host_kind="e2e",
240
+ data_dir=str(paths.data_dir),
241
+ session_context=str(paths.session_context),
242
+ streaming="none",
243
+ in_process_mcp=True,
244
+ )
245
+ if runtime.session.restart_required:
246
+ outcome.aborted = "runtime requested a restart during boot (pack auto-install?)"
247
+ return outcome
248
+ asyncio.run(_drive(runtime, scenario, outcome))
249
+ return outcome
e2etest/environment.py ADDED
@@ -0,0 +1,134 @@
1
+ """Isolated run directory: data dir, config, fixture repo, and process env."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from string import Template
11
+
12
+ from dotenv import load_dotenv
13
+
14
+ from e2etest.scenario import Scenario
15
+
16
+ _DEFAULT_POLICY = """\
17
+ version: 1
18
+ mode: ask
19
+ sandbox_downgrade: true
20
+ bare_requires_approval: false
21
+ allow: []
22
+ ask: []
23
+ deny: []
24
+ """
25
+ # Operator location overrides that would point the run back at real state.
26
+ _LOCATION_ENV = (
27
+ "LISA_PROFILE",
28
+ "LISA_THREAD_DIR",
29
+ "LISA_SESSION_CONTEXT_PATH",
30
+ "LISA_MCP_LOG_PATH",
31
+ )
32
+ _GIT_IDENTITY = {
33
+ "GIT_AUTHOR_NAME": "lisa-e2e",
34
+ "GIT_AUTHOR_EMAIL": "lisa-e2e@localhost",
35
+ "GIT_COMMITTER_NAME": "lisa-e2e",
36
+ "GIT_COMMITTER_EMAIL": "lisa-e2e@localhost",
37
+ }
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class RunPaths:
42
+ """Every location a run touches; all live under ``run_dir``."""
43
+
44
+ run_dir: Path
45
+ data_dir: Path
46
+ workspace: Path | None
47
+ remote: Path | None
48
+ initial_main_sha: str
49
+ scenario_dir: Path | None = None
50
+
51
+ @property
52
+ def session_context(self) -> Path:
53
+ return self.data_dir / "config" / "session_context.md"
54
+
55
+ @property
56
+ def files_root(self) -> Path:
57
+ """The lisa-storage sandbox root (``StorageBackend.files_root()``)."""
58
+ return self.data_dir / "data" / "files"
59
+
60
+
61
+ def base_data_dir() -> Path:
62
+ """The operator's real data dir; only models.yaml and .env are read from it."""
63
+ override = os.getenv("LISA_E2E_BASE_DATA_DIR", "").strip()
64
+ return Path(override).expanduser() if override else Path.home() / ".lisa"
65
+
66
+
67
+ def git(cwd: Path, *args: str) -> str:
68
+ proc = subprocess.run(
69
+ ["git", *args],
70
+ cwd=cwd,
71
+ capture_output=True,
72
+ text=True,
73
+ check=True,
74
+ env={**os.environ, **_GIT_IDENTITY},
75
+ )
76
+ return proc.stdout.strip()
77
+
78
+
79
+ def _seed_fixture(scenario: Scenario, run_dir: Path) -> tuple[Path | None, Path | None, str]:
80
+ if scenario.fixture_repo is None:
81
+ return None, None, ""
82
+ workspace = run_dir / "workspace" / scenario.fixture_repo.name
83
+ shutil.copytree(
84
+ scenario.fixture_repo, workspace, ignore=shutil.ignore_patterns("__pycache__", "*.pyc")
85
+ )
86
+ git(workspace, "init", "--quiet", "--initial-branch", "main")
87
+ for key, value in (("user.name", "lisa-e2e"), ("user.email", "lisa-e2e@localhost")):
88
+ git(workspace, "config", key, value)
89
+ git(workspace, "add", "-A")
90
+ git(workspace, "commit", "--quiet", "-m", "fixture: initial state")
91
+ remote = run_dir / "remote.git"
92
+ git(run_dir, "init", "--quiet", "--bare", str(remote))
93
+ git(workspace, "remote", "add", "origin", str(remote))
94
+ git(workspace, "push", "--quiet", "origin", "main")
95
+ return workspace, remote, git(workspace, "rev-parse", "HEAD")
96
+
97
+
98
+ def prepare_run(scenario: Scenario, run_dir: Path, packs_venv: Path) -> RunPaths:
99
+ """Create the run dir, seed config + fixture, and point the process env at it."""
100
+ data_dir = run_dir / "data"
101
+ config_dir = data_dir / "config"
102
+ config_dir.mkdir(parents=True)
103
+ base = base_data_dir()
104
+ models = base / "config" / "models.yaml"
105
+ if not models.is_file():
106
+ raise FileNotFoundError(f"base models.yaml not found: {models}")
107
+ shutil.copy2(models, config_dir / "models.yaml")
108
+ policy_text = (
109
+ scenario.policy.read_text(encoding="utf-8") if scenario.policy else _DEFAULT_POLICY
110
+ )
111
+ (config_dir / "policy.yaml").write_text(policy_text, encoding="utf-8")
112
+
113
+ workspace, remote, initial_sha = _seed_fixture(scenario, run_dir)
114
+ paths = RunPaths(run_dir, data_dir, workspace, remote, initial_sha, scenario.base_dir)
115
+ if scenario.fixture_data_files is not None:
116
+ shutil.copytree(scenario.fixture_data_files, paths.files_root)
117
+ template = Template(scenario.session_context.read_text(encoding="utf-8"))
118
+ paths.session_context.write_text(
119
+ template.substitute(
120
+ RUN_DIR=str(run_dir),
121
+ WORKSPACE=str(workspace or ""),
122
+ WORKSPACE_PARENT=str(workspace.parent if workspace else ""),
123
+ ),
124
+ encoding="utf-8",
125
+ )
126
+
127
+ load_dotenv(base / ".env", override=False)
128
+ for name in _LOCATION_ENV:
129
+ os.environ.pop(name, None)
130
+ os.environ["LISA_DATA_DIR"] = str(data_dir)
131
+ os.environ["LISA_PACKS_VENV"] = str(packs_venv)
132
+ os.environ.update(_GIT_IDENTITY)
133
+ os.environ.update(scenario.env)
134
+ return paths