ecp-runtime 0.2.9b0__tar.gz → 0.3.0__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.
- {ecp_runtime-0.2.9b0 → ecp_runtime-0.3.0}/.gitignore +5 -2
- {ecp_runtime-0.2.9b0 → ecp_runtime-0.3.0}/PKG-INFO +11 -2
- {ecp_runtime-0.2.9b0 → ecp_runtime-0.3.0}/README.md +10 -1
- {ecp_runtime-0.2.9b0 → ecp_runtime-0.3.0}/pyproject.toml +1 -2
- ecp_runtime-0.3.0/src/ecp_runtime/__init__.py +1 -0
- {ecp_runtime-0.2.9b0 → ecp_runtime-0.3.0}/src/ecp_runtime/cli.py +69 -5
- {ecp_runtime-0.2.9b0 → ecp_runtime-0.3.0}/src/ecp_runtime/graders.py +2 -1
- {ecp_runtime-0.2.9b0 → ecp_runtime-0.3.0}/src/ecp_runtime/manifest.py +3 -1
- {ecp_runtime-0.2.9b0 → ecp_runtime-0.3.0}/src/ecp_runtime/runner.py +88 -6
- ecp_runtime-0.3.0/src/ecp_runtime/trend.py +113 -0
- {ecp_runtime-0.2.9b0 → ecp_runtime-0.3.0}/tests/test_cli.py +2 -3
- ecp_runtime-0.3.0/tests/test_example_integrations.py +135 -0
- {ecp_runtime-0.2.9b0 → ecp_runtime-0.3.0}/tests/test_runner.py +46 -2
- ecp_runtime-0.3.0/tests/test_trend.py +87 -0
- ecp_runtime-0.2.9b0/src/ecp_runtime/__init__.py +0 -1
- {ecp_runtime-0.2.9b0 → ecp_runtime-0.3.0}/src/ecp_runtime/reporter.py +0 -0
- {ecp_runtime-0.2.9b0 → ecp_runtime-0.3.0}/tests/__init__.py +0 -0
- {ecp_runtime-0.2.9b0 → ecp_runtime-0.3.0}/tests/test_graders.py +1 -1
- {ecp_runtime-0.2.9b0 → ecp_runtime-0.3.0}/tests/test_manifest.py +1 -1
|
@@ -31,7 +31,7 @@ MANIFEST
|
|
|
31
31
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
32
|
*.manifest
|
|
33
33
|
*.spec
|
|
34
|
-
|
|
34
|
+
/specs/
|
|
35
35
|
# Installer logs
|
|
36
36
|
pip-log.txt
|
|
37
37
|
pip-delete-this-directory.txt
|
|
@@ -50,7 +50,10 @@ coverage.xml
|
|
|
50
50
|
.hypothesis/
|
|
51
51
|
.pytest_cache/
|
|
52
52
|
cover/
|
|
53
|
-
|
|
53
|
+
node_modules/
|
|
54
|
+
.github/agents
|
|
55
|
+
.github/prompts/
|
|
56
|
+
.specify/
|
|
54
57
|
# Translations
|
|
55
58
|
*.mo
|
|
56
59
|
*.pot
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ecp-runtime
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: The reference runtime for the Evaluation Context Protocol (ECP).
|
|
5
5
|
Project-URL: Homepage, https://github.com/evaluation-context-protocol/ecp
|
|
6
6
|
Project-URL: Documentation, https://evaluationcontextprotocol.io/
|
|
@@ -24,9 +24,11 @@ It includes the CLI tool `ecp` for running agent evaluations.
|
|
|
24
24
|
## Install
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
|
-
pip install ecp-runtime
|
|
27
|
+
pip install "ecp-runtime==0.2.9"
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
The latest stable PyPI release is now `0.2.9` and matches the current GitHub release line.
|
|
31
|
+
|
|
30
32
|
## Usage
|
|
31
33
|
|
|
32
34
|
Run an evaluation manifest:
|
|
@@ -41,6 +43,13 @@ You can also run via module entrypoint:
|
|
|
41
43
|
python -m ecp_runtime.cli run --manifest .\examples\langchain_demo\manifest.yaml
|
|
42
44
|
```
|
|
43
45
|
|
|
46
|
+
Manifest `target` values may be either a command for the default stdio transport
|
|
47
|
+
or an ECP Streamable HTTP endpoint:
|
|
48
|
+
|
|
49
|
+
```yaml
|
|
50
|
+
target: "http://127.0.0.1:8765/ecp"
|
|
51
|
+
```
|
|
52
|
+
|
|
44
53
|
If your manifest includes `llm_judge`, set an API key and optional judge model:
|
|
45
54
|
|
|
46
55
|
```bash
|
|
@@ -6,9 +6,11 @@ It includes the CLI tool `ecp` for running agent evaluations.
|
|
|
6
6
|
## Install
|
|
7
7
|
|
|
8
8
|
```bash
|
|
9
|
-
pip install ecp-runtime
|
|
9
|
+
pip install "ecp-runtime==0.2.9"
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
+
The latest stable PyPI release is now `0.2.9` and matches the current GitHub release line.
|
|
13
|
+
|
|
12
14
|
## Usage
|
|
13
15
|
|
|
14
16
|
Run an evaluation manifest:
|
|
@@ -23,6 +25,13 @@ You can also run via module entrypoint:
|
|
|
23
25
|
python -m ecp_runtime.cli run --manifest .\examples\langchain_demo\manifest.yaml
|
|
24
26
|
```
|
|
25
27
|
|
|
28
|
+
Manifest `target` values may be either a command for the default stdio transport
|
|
29
|
+
or an ECP Streamable HTTP endpoint:
|
|
30
|
+
|
|
31
|
+
```yaml
|
|
32
|
+
target: "http://127.0.0.1:8765/ecp"
|
|
33
|
+
```
|
|
34
|
+
|
|
26
35
|
If your manifest includes `llm_judge`, set an API key and optional judge model:
|
|
27
36
|
|
|
28
37
|
```bash
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ecp-runtime"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "The reference runtime for the Evaluation Context Protocol (ECP)."
|
|
9
9
|
authors = [
|
|
10
10
|
{ name = "ECP Maintainers", email = "hello@ecp.org" },
|
|
@@ -29,4 +29,3 @@ Issues = "https://github.com/evaluation-context-protocol/ecp/issues"
|
|
|
29
29
|
[project.scripts]
|
|
30
30
|
ecp = "ecp_runtime.cli:app"
|
|
31
31
|
|
|
32
|
-
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.9"
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import
|
|
2
|
-
import typer
|
|
3
|
-
import sys
|
|
4
|
-
import os
|
|
1
|
+
import glob
|
|
5
2
|
import json
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
6
|
from pathlib import Path
|
|
7
|
-
from typing import Dict,
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
8
10
|
|
|
9
11
|
from .reporter import HTMLReporter
|
|
12
|
+
from .trend import RunTrendAnalyzer
|
|
10
13
|
|
|
11
14
|
# Import local modules (Using relative imports)
|
|
12
15
|
try:
|
|
@@ -137,5 +140,66 @@ def run(
|
|
|
137
140
|
sys.exit(1)
|
|
138
141
|
|
|
139
142
|
|
|
143
|
+
@app.command()
|
|
144
|
+
def trend(
|
|
145
|
+
pattern: str = typer.Argument(
|
|
146
|
+
...,
|
|
147
|
+
help="Glob pattern matching saved JSON report files (e.g. 'results/run-*.json')",
|
|
148
|
+
),
|
|
149
|
+
window: int = typer.Option(
|
|
150
|
+
20,
|
|
151
|
+
"--window",
|
|
152
|
+
"-w",
|
|
153
|
+
help="Maximum number of recent runs to include in the analysis",
|
|
154
|
+
min=1,
|
|
155
|
+
),
|
|
156
|
+
exit_on_regression: bool = typer.Option(
|
|
157
|
+
False,
|
|
158
|
+
"--exit-on-regression",
|
|
159
|
+
help="Exit with code 2 when a degrading trend is detected (for CI gates)",
|
|
160
|
+
),
|
|
161
|
+
verbose: bool = typer.Option(
|
|
162
|
+
False,
|
|
163
|
+
"--verbose",
|
|
164
|
+
"-v",
|
|
165
|
+
help="Enable verbose output",
|
|
166
|
+
),
|
|
167
|
+
):
|
|
168
|
+
"""
|
|
169
|
+
Analyse pass-rate trends across a sequence of saved JSON report files.
|
|
170
|
+
"""
|
|
171
|
+
_configure_logging(verbose)
|
|
172
|
+
|
|
173
|
+
matched: List[Path] = sorted(Path(path) for path in glob.glob(pattern, recursive=True))
|
|
174
|
+
|
|
175
|
+
if not matched:
|
|
176
|
+
typer.echo(f"No files matched the pattern: {pattern}", err=True)
|
|
177
|
+
raise typer.Exit(code=1)
|
|
178
|
+
|
|
179
|
+
typer.echo(f"Found {len(matched)} report(s). Analysing last {window}...")
|
|
180
|
+
|
|
181
|
+
analyzer = RunTrendAnalyzer(matched, window=window)
|
|
182
|
+
report = analyzer.analyze()
|
|
183
|
+
|
|
184
|
+
typer.echo("")
|
|
185
|
+
typer.echo(f"{'Run':<5} {'Manifest':<45} {'Passed':>6} {'Total':>6} {'Rate':>6}")
|
|
186
|
+
typer.echo("-" * 74)
|
|
187
|
+
for index, run in enumerate(report.runs, start=1):
|
|
188
|
+
manifest_label = Path(run.manifest).name if len(run.manifest) > 45 else run.manifest
|
|
189
|
+
rate_pct = f"{run.pass_rate * 100:.1f}%"
|
|
190
|
+
typer.echo(f"{index:<5} {manifest_label:<45} {run.passed:>6} {run.total:>6} {rate_pct:>6}")
|
|
191
|
+
|
|
192
|
+
typer.echo("")
|
|
193
|
+
typer.echo(f"Trend Direction : {report.direction.upper()}")
|
|
194
|
+
typer.echo(f"Slope (per run) : {report.pass_rate_slope:+.6f}")
|
|
195
|
+
typer.echo(f"Window : {len(report.runs)} run(s) analysed (max {report.window})")
|
|
196
|
+
typer.echo(f"Regression flag : {'YES' if report.any_regression else 'No'}")
|
|
197
|
+
typer.echo("")
|
|
198
|
+
|
|
199
|
+
if report.any_regression and exit_on_regression:
|
|
200
|
+
typer.echo("Regression detected. Exiting with code 2 (--exit-on-regression).", err=True)
|
|
201
|
+
raise typer.Exit(code=2)
|
|
202
|
+
|
|
203
|
+
|
|
140
204
|
if __name__ == "__main__":
|
|
141
205
|
app()
|
|
@@ -5,15 +5,18 @@ Simplified Version. V0.1
|
|
|
5
5
|
AsyncIO Pending
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
import subprocess
|
|
9
8
|
import json
|
|
10
|
-
import os
|
|
11
|
-
import time
|
|
12
9
|
import logging
|
|
13
|
-
import
|
|
10
|
+
import os
|
|
14
11
|
import queue
|
|
15
|
-
|
|
12
|
+
import subprocess
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
16
15
|
from dataclasses import dataclass
|
|
16
|
+
from typing import Any, Dict, List, Optional
|
|
17
|
+
from urllib import error, request
|
|
18
|
+
from urllib.parse import urlparse
|
|
19
|
+
|
|
17
20
|
from .graders import evaluate_step
|
|
18
21
|
|
|
19
22
|
logger = logging.getLogger(__name__)
|
|
@@ -147,6 +150,75 @@ class AgentProcess:
|
|
|
147
150
|
return ""
|
|
148
151
|
|
|
149
152
|
|
|
153
|
+
class HTTPAgentClient:
|
|
154
|
+
"""JSON-RPC client for ECP Streamable HTTP endpoints."""
|
|
155
|
+
|
|
156
|
+
def __init__(self, endpoint: str, rpc_timeout: float = 30.0):
|
|
157
|
+
self.endpoint = endpoint
|
|
158
|
+
self.rpc_timeout = rpc_timeout
|
|
159
|
+
|
|
160
|
+
def start(self):
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
def stop(self):
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
def send_rpc(self, method: str, params: Dict[str, Any] = None) -> Dict[str, Any]:
|
|
167
|
+
if not params:
|
|
168
|
+
params = {}
|
|
169
|
+
|
|
170
|
+
payload = {
|
|
171
|
+
"jsonrpc": "2.0",
|
|
172
|
+
"method": method,
|
|
173
|
+
"params": params,
|
|
174
|
+
"id": int(time.time() * 1000),
|
|
175
|
+
}
|
|
176
|
+
body = json.dumps(payload).encode("utf-8")
|
|
177
|
+
req = request.Request(
|
|
178
|
+
self.endpoint,
|
|
179
|
+
data=body,
|
|
180
|
+
method="POST",
|
|
181
|
+
headers={
|
|
182
|
+
"Accept": "application/json, text/event-stream",
|
|
183
|
+
"Content-Type": "application/json",
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
with request.urlopen(req, timeout=self.rpc_timeout) as resp:
|
|
189
|
+
content_type = resp.headers.get("Content-Type", "")
|
|
190
|
+
raw = resp.read().decode("utf-8")
|
|
191
|
+
except error.HTTPError as exc:
|
|
192
|
+
raw = exc.read().decode("utf-8", errors="replace")
|
|
193
|
+
raise RuntimeError(
|
|
194
|
+
f"HTTP RPC failed: status={exc.code}, body={raw or exc.reason}"
|
|
195
|
+
) from exc
|
|
196
|
+
except error.URLError as exc:
|
|
197
|
+
raise RuntimeError(f"HTTP RPC failed: {exc.reason}") from exc
|
|
198
|
+
|
|
199
|
+
if "text/event-stream" in content_type:
|
|
200
|
+
return self._parse_sse_response(raw)
|
|
201
|
+
if not raw:
|
|
202
|
+
raise RuntimeError("HTTP RPC response was empty")
|
|
203
|
+
payload = json.loads(raw)
|
|
204
|
+
if not isinstance(payload, dict):
|
|
205
|
+
raise RuntimeError("HTTP RPC response must be a JSON object")
|
|
206
|
+
return payload
|
|
207
|
+
|
|
208
|
+
def _parse_sse_response(self, raw: str) -> Dict[str, Any]:
|
|
209
|
+
for event in raw.split("\n\n"):
|
|
210
|
+
data_lines = []
|
|
211
|
+
for line in event.splitlines():
|
|
212
|
+
if line.startswith("data:"):
|
|
213
|
+
data_lines.append(line[5:].lstrip())
|
|
214
|
+
if not data_lines:
|
|
215
|
+
continue
|
|
216
|
+
payload = json.loads("\n".join(data_lines))
|
|
217
|
+
if isinstance(payload, dict) and ("result" in payload or "error" in payload):
|
|
218
|
+
return payload
|
|
219
|
+
raise RuntimeError("SSE stream ended without a JSON-RPC response")
|
|
220
|
+
|
|
221
|
+
|
|
150
222
|
class ECPRunner:
|
|
151
223
|
"""The Orchestrator."""
|
|
152
224
|
|
|
@@ -162,7 +234,7 @@ class ECPRunner:
|
|
|
162
234
|
logger.info("Scenario: %s", scenario.name)
|
|
163
235
|
|
|
164
236
|
rpc_timeout = float(os.environ.get("ECP_RPC_TIMEOUT", "30"))
|
|
165
|
-
agent =
|
|
237
|
+
agent = self._create_agent(self.manifest.target, rpc_timeout=rpc_timeout)
|
|
166
238
|
agent.start()
|
|
167
239
|
|
|
168
240
|
try:
|
|
@@ -224,6 +296,11 @@ class ECPRunner:
|
|
|
224
296
|
"scenarios": report_data
|
|
225
297
|
}
|
|
226
298
|
|
|
299
|
+
def _create_agent(self, target: str, rpc_timeout: float):
|
|
300
|
+
if _is_http_url(target):
|
|
301
|
+
return HTTPAgentClient(target, rpc_timeout=rpc_timeout)
|
|
302
|
+
return AgentProcess(target, rpc_timeout=rpc_timeout)
|
|
303
|
+
|
|
227
304
|
def _ensure_rpc_success(
|
|
228
305
|
self,
|
|
229
306
|
rpc_resp: Dict[str, Any],
|
|
@@ -243,3 +320,8 @@ class ECPRunner:
|
|
|
243
320
|
raise RuntimeError(
|
|
244
321
|
f"RPC call failed ({method}) at {where}: code={code}, message={message}"
|
|
245
322
|
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _is_http_url(target: str) -> bool:
|
|
326
|
+
parsed = urlparse(target)
|
|
327
|
+
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ecp_runtime.trend
|
|
3
|
+
=================
|
|
4
|
+
RunTrendAnalyzer - reads a sequence of saved JSON report files produced by
|
|
5
|
+
``ecp run --json-out`` and computes cross-run pass-rate regression signals.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import statistics
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import List, Literal
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class RunPoint:
|
|
19
|
+
"""Represents a single evaluation run extracted from one JSON report."""
|
|
20
|
+
|
|
21
|
+
manifest: str
|
|
22
|
+
passed: int
|
|
23
|
+
total: int
|
|
24
|
+
pass_rate: float
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class RunTrendReport:
|
|
29
|
+
"""Aggregated analysis over a window of sequential runs."""
|
|
30
|
+
|
|
31
|
+
window: int
|
|
32
|
+
runs: List[RunPoint] = field(default_factory=list)
|
|
33
|
+
pass_rate_slope: float = 0.0
|
|
34
|
+
direction: Literal["improving", "degrading", "stable"] = "stable"
|
|
35
|
+
any_regression: bool = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RunTrendAnalyzer:
|
|
39
|
+
"""
|
|
40
|
+
Analyse cross-run pass-rate trends across a collection of JSON reports.
|
|
41
|
+
|
|
42
|
+
The analyzer sorts report paths lexicographically and keeps only the most
|
|
43
|
+
recent ``window`` reports.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
_IMPROVING_THRESHOLD: float = 0.001
|
|
47
|
+
_DEGRADING_THRESHOLD: float = -0.001
|
|
48
|
+
|
|
49
|
+
def __init__(self, report_paths: List[Path], window: int = 20) -> None:
|
|
50
|
+
if window < 1:
|
|
51
|
+
raise ValueError(f"window must be >= 1, got {window}")
|
|
52
|
+
self.report_paths: List[Path] = sorted(report_paths)[-window:]
|
|
53
|
+
self.window = window
|
|
54
|
+
|
|
55
|
+
def analyze(self) -> RunTrendReport:
|
|
56
|
+
runs: List[RunPoint] = []
|
|
57
|
+
|
|
58
|
+
for path in self.report_paths:
|
|
59
|
+
point = self._load_run_point(path)
|
|
60
|
+
if point is not None:
|
|
61
|
+
runs.append(point)
|
|
62
|
+
|
|
63
|
+
if len(runs) < 2:
|
|
64
|
+
return RunTrendReport(
|
|
65
|
+
window=self.window,
|
|
66
|
+
runs=runs,
|
|
67
|
+
pass_rate_slope=0.0,
|
|
68
|
+
direction="stable",
|
|
69
|
+
any_regression=False,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
slope = self._compute_slope([run.pass_rate for run in runs])
|
|
73
|
+
direction = self._classify(slope)
|
|
74
|
+
|
|
75
|
+
return RunTrendReport(
|
|
76
|
+
window=self.window,
|
|
77
|
+
runs=runs,
|
|
78
|
+
pass_rate_slope=round(slope, 6),
|
|
79
|
+
direction=direction,
|
|
80
|
+
any_regression=(direction == "degrading"),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def _load_run_point(path: Path) -> RunPoint | None:
|
|
85
|
+
try:
|
|
86
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
87
|
+
except (OSError, json.JSONDecodeError):
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
total = int(data.get("total", 0) or 0)
|
|
91
|
+
passed = int(data.get("passed", 0) or 0)
|
|
92
|
+
passed = max(0, min(passed, total))
|
|
93
|
+
pass_rate = passed / total if total > 0 else 0.0
|
|
94
|
+
|
|
95
|
+
return RunPoint(
|
|
96
|
+
manifest=data.get("manifest", str(path)),
|
|
97
|
+
passed=passed,
|
|
98
|
+
total=total,
|
|
99
|
+
pass_rate=pass_rate,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def _compute_slope(pass_rates: List[float]) -> float:
|
|
104
|
+
x = list(range(len(pass_rates)))
|
|
105
|
+
slope, _intercept = statistics.linear_regression(x, pass_rates)
|
|
106
|
+
return slope
|
|
107
|
+
|
|
108
|
+
def _classify(self, slope: float) -> Literal["improving", "degrading", "stable"]:
|
|
109
|
+
if slope > self._IMPROVING_THRESHOLD:
|
|
110
|
+
return "improving"
|
|
111
|
+
if slope < self._DEGRADING_THRESHOLD:
|
|
112
|
+
return "degrading"
|
|
113
|
+
return "stable"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import sys
|
|
2
3
|
import tempfile
|
|
3
4
|
import unittest
|
|
4
|
-
import sys
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
from unittest import mock
|
|
7
7
|
|
|
@@ -9,9 +9,8 @@ RUNTIME_SRC = Path(__file__).resolve().parents[1] / "src"
|
|
|
9
9
|
if str(RUNTIME_SRC) not in sys.path:
|
|
10
10
|
sys.path.insert(0, str(RUNTIME_SRC))
|
|
11
11
|
|
|
12
|
-
from typer.testing import CliRunner
|
|
13
|
-
|
|
14
12
|
from ecp_runtime.cli import app
|
|
13
|
+
from typer.testing import CliRunner
|
|
15
14
|
|
|
16
15
|
|
|
17
16
|
class CLISmokeTests(unittest.TestCase):
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import socket
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import textwrap
|
|
7
|
+
import time
|
|
8
|
+
import unittest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
12
|
+
RUNTIME_SRC = Path(__file__).resolve().parents[1] / "src"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ExampleIntegrationTests(unittest.TestCase):
|
|
16
|
+
def _run_manifest(self, manifest_path: Path) -> subprocess.CompletedProcess[str]:
|
|
17
|
+
env = dict(os.environ)
|
|
18
|
+
existing = env.get("PYTHONPATH", "")
|
|
19
|
+
extra_paths = [str(RUNTIME_SRC)]
|
|
20
|
+
if existing:
|
|
21
|
+
extra_paths.append(existing)
|
|
22
|
+
env["PYTHONPATH"] = os.pathsep.join(extra_paths)
|
|
23
|
+
|
|
24
|
+
return subprocess.run(
|
|
25
|
+
[
|
|
26
|
+
sys.executable,
|
|
27
|
+
"-m",
|
|
28
|
+
"ecp_runtime.cli",
|
|
29
|
+
"run",
|
|
30
|
+
"--manifest",
|
|
31
|
+
str(manifest_path),
|
|
32
|
+
"--json",
|
|
33
|
+
],
|
|
34
|
+
cwd=REPO_ROOT,
|
|
35
|
+
text=True,
|
|
36
|
+
capture_output=True,
|
|
37
|
+
env=env,
|
|
38
|
+
check=False,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def test_plain_python_demo_manifest_passes(self) -> None:
|
|
42
|
+
manifest = REPO_ROOT / "examples" / "plain_python_demo" / "manifest.yaml"
|
|
43
|
+
result = self._run_manifest(manifest)
|
|
44
|
+
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
|
45
|
+
self.assertIn('"failed": 0', result.stdout)
|
|
46
|
+
|
|
47
|
+
def test_two_agent_demo_manifest_passes(self) -> None:
|
|
48
|
+
manifest = REPO_ROOT / "examples" / "two_agent_demo" / "manifest.yaml"
|
|
49
|
+
result = self._run_manifest(manifest)
|
|
50
|
+
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
|
51
|
+
self.assertIn('"failed": 0', result.stdout)
|
|
52
|
+
|
|
53
|
+
def test_streamable_http_demo_manifest_passes(self) -> None:
|
|
54
|
+
port = self._free_port()
|
|
55
|
+
server_env = dict(os.environ)
|
|
56
|
+
sdk_src = REPO_ROOT / "sdk" / "python" / "src"
|
|
57
|
+
existing = server_env.get("PYTHONPATH", "")
|
|
58
|
+
extra_paths = [str(sdk_src)]
|
|
59
|
+
if existing:
|
|
60
|
+
extra_paths.append(existing)
|
|
61
|
+
server_env["PYTHONPATH"] = os.pathsep.join(extra_paths)
|
|
62
|
+
server_env["ECP_HTTP_PORT"] = str(port)
|
|
63
|
+
|
|
64
|
+
server = subprocess.Popen(
|
|
65
|
+
[sys.executable, str(REPO_ROOT / "examples" / "streamable_http_demo" / "agent.py")],
|
|
66
|
+
cwd=REPO_ROOT,
|
|
67
|
+
text=True,
|
|
68
|
+
stdout=subprocess.PIPE,
|
|
69
|
+
stderr=subprocess.PIPE,
|
|
70
|
+
env=server_env,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
self._wait_for_port("127.0.0.1", port)
|
|
75
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
76
|
+
manifest_path = Path(tmpdir) / "streamable-http-manifest.yaml"
|
|
77
|
+
manifest_path.write_text(
|
|
78
|
+
textwrap.dedent(
|
|
79
|
+
f"""
|
|
80
|
+
manifest_version: "v1"
|
|
81
|
+
name: "Streamable HTTP Transport Validation"
|
|
82
|
+
target: "http://127.0.0.1:{port}/ecp"
|
|
83
|
+
|
|
84
|
+
scenarios:
|
|
85
|
+
- name: "HTTP Echo"
|
|
86
|
+
steps:
|
|
87
|
+
- input: "echo: transport is online"
|
|
88
|
+
graders:
|
|
89
|
+
- type: text_match
|
|
90
|
+
field: public_output
|
|
91
|
+
condition: contains
|
|
92
|
+
value: "transport is online"
|
|
93
|
+
- type: tool_usage
|
|
94
|
+
tool_name: "http_echo"
|
|
95
|
+
arguments:
|
|
96
|
+
message: "transport is online"
|
|
97
|
+
"""
|
|
98
|
+
).strip(),
|
|
99
|
+
encoding="utf-8",
|
|
100
|
+
)
|
|
101
|
+
result = self._run_manifest(manifest_path)
|
|
102
|
+
|
|
103
|
+
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
|
104
|
+
self.assertIn('"failed": 0', result.stdout)
|
|
105
|
+
finally:
|
|
106
|
+
server.terminate()
|
|
107
|
+
try:
|
|
108
|
+
server.communicate(timeout=2)
|
|
109
|
+
except subprocess.TimeoutExpired:
|
|
110
|
+
server.kill()
|
|
111
|
+
server.communicate()
|
|
112
|
+
|
|
113
|
+
def _free_port(self) -> int:
|
|
114
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
115
|
+
sock.bind(("127.0.0.1", 0))
|
|
116
|
+
return int(sock.getsockname()[1])
|
|
117
|
+
|
|
118
|
+
def _wait_for_port(self, host: str, port: int) -> None:
|
|
119
|
+
deadline = time.time() + 5
|
|
120
|
+
while time.time() < deadline:
|
|
121
|
+
if self._port_is_open(host, port):
|
|
122
|
+
return
|
|
123
|
+
time.sleep(0.05)
|
|
124
|
+
raise AssertionError(f"Timed out waiting for {host}:{port}")
|
|
125
|
+
|
|
126
|
+
def _port_is_open(self, host: str, port: int) -> bool:
|
|
127
|
+
try:
|
|
128
|
+
with socket.create_connection((host, port), timeout=0.2):
|
|
129
|
+
return True
|
|
130
|
+
except OSError:
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
unittest.main()
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import unittest
|
|
2
1
|
import sys
|
|
2
|
+
import unittest
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from types import SimpleNamespace
|
|
5
5
|
from unittest import mock
|
|
@@ -9,7 +9,7 @@ if str(RUNTIME_SRC) not in sys.path:
|
|
|
9
9
|
sys.path.insert(0, str(RUNTIME_SRC))
|
|
10
10
|
|
|
11
11
|
from ecp_runtime.manifest import StepConfig
|
|
12
|
-
from ecp_runtime.runner import ECPRunner
|
|
12
|
+
from ecp_runtime.runner import ECPRunner, HTTPAgentClient
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
class RunnerTests(unittest.TestCase):
|
|
@@ -68,6 +68,50 @@ class RunnerTests(unittest.TestCase):
|
|
|
68
68
|
self.assertIn("step=1", msg)
|
|
69
69
|
self.assertIn("boom", msg)
|
|
70
70
|
|
|
71
|
+
def test_runner_uses_http_client_for_url_target(self) -> None:
|
|
72
|
+
runner = ECPRunner(SimpleNamespace(target="http://127.0.0.1:8765/ecp", scenarios=[]))
|
|
73
|
+
|
|
74
|
+
agent = runner._create_agent(runner.manifest.target, rpc_timeout=12.0)
|
|
75
|
+
|
|
76
|
+
self.assertIsInstance(agent, HTTPAgentClient)
|
|
77
|
+
self.assertEqual(agent.endpoint, "http://127.0.0.1:8765/ecp")
|
|
78
|
+
self.assertEqual(agent.rpc_timeout, 12.0)
|
|
79
|
+
|
|
80
|
+
def test_http_agent_client_posts_json_rpc(self) -> None:
|
|
81
|
+
class FakeResponse:
|
|
82
|
+
headers = {"Content-Type": "application/json; charset=utf-8"}
|
|
83
|
+
|
|
84
|
+
def __enter__(self):
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def __exit__(self, *_args):
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
def read(self):
|
|
91
|
+
return b'{"jsonrpc":"2.0","id":1,"result":{"ok":true}}'
|
|
92
|
+
|
|
93
|
+
captured = {}
|
|
94
|
+
|
|
95
|
+
def fake_urlopen(req, timeout):
|
|
96
|
+
captured["url"] = req.full_url
|
|
97
|
+
captured["timeout"] = timeout
|
|
98
|
+
captured["body"] = req.data
|
|
99
|
+
captured["accept"] = req.headers.get("Accept")
|
|
100
|
+
return FakeResponse()
|
|
101
|
+
|
|
102
|
+
with mock.patch("ecp_runtime.runner.request.urlopen", fake_urlopen):
|
|
103
|
+
response = HTTPAgentClient("http://agent.test/ecp", rpc_timeout=3).send_rpc(
|
|
104
|
+
"agent/step", {"input": "hi"}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
self.assertEqual(response["result"]["ok"], True)
|
|
108
|
+
self.assertEqual(captured["url"], "http://agent.test/ecp")
|
|
109
|
+
self.assertEqual(captured["timeout"], 3)
|
|
110
|
+
self.assertIn("application/json", captured["accept"])
|
|
111
|
+
body = captured["body"].decode("utf-8")
|
|
112
|
+
self.assertIn('"method": "agent/step"', body)
|
|
113
|
+
self.assertIn('"input": "hi"', body)
|
|
114
|
+
|
|
71
115
|
|
|
72
116
|
if __name__ == "__main__":
|
|
73
117
|
unittest.main()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from unittest import mock
|
|
7
|
+
|
|
8
|
+
# Ensure srd is in path for imports
|
|
9
|
+
RUNTIME_SRC = Path(__file__).resolve().parents[1] / "src"
|
|
10
|
+
if str(RUNTIME_SRC) not in sys.path:
|
|
11
|
+
sys.path.insert(0, str(RUNTIME_SRC))
|
|
12
|
+
|
|
13
|
+
from ecp_runtime.trend import RunPoint, RunTrendAnalyzer
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RunTrendAnalyzerTests(unittest.TestCase):
|
|
17
|
+
def test_analyzer_handles_improving_trend(self) -> None:
|
|
18
|
+
rates = [0.1, 0.5, 0.9]
|
|
19
|
+
runs = [RunPoint(manifest=f"r{i}", passed=int(r*10), total=10, pass_rate=r) for i, r in enumerate(rates)]
|
|
20
|
+
|
|
21
|
+
# We mock _load_run_point to avoid file IO for the core logic test
|
|
22
|
+
with mock.patch.object(RunTrendAnalyzer, "_load_run_point", side_effect=runs):
|
|
23
|
+
analyzer = RunTrendAnalyzer([Path("r0"), Path("r1"), Path("r2")])
|
|
24
|
+
report = analyzer.analyze()
|
|
25
|
+
|
|
26
|
+
self.assertEqual(report.direction, "improving")
|
|
27
|
+
self.assertGreater(report.pass_rate_slope, 0.3)
|
|
28
|
+
self.assertFalse(report.any_regression)
|
|
29
|
+
|
|
30
|
+
def test_analyzer_handles_degrading_trend(self) -> None:
|
|
31
|
+
rates = [0.9, 0.5, 0.1]
|
|
32
|
+
runs = [RunPoint(manifest=f"r{i}", passed=int(r*10), total=10, pass_rate=r) for i, r in enumerate(rates)]
|
|
33
|
+
|
|
34
|
+
with mock.patch.object(RunTrendAnalyzer, "_load_run_point", side_effect=runs):
|
|
35
|
+
analyzer = RunTrendAnalyzer([Path("r0"), Path("r1"), Path("r2")])
|
|
36
|
+
report = analyzer.analyze()
|
|
37
|
+
|
|
38
|
+
self.assertEqual(report.direction, "degrading")
|
|
39
|
+
self.assertLess(report.pass_rate_slope, -0.3)
|
|
40
|
+
self.assertTrue(report.any_regression)
|
|
41
|
+
|
|
42
|
+
def test_analyzer_handles_stable_trend(self) -> None:
|
|
43
|
+
rates = [0.5, 0.50001, 0.49999]
|
|
44
|
+
runs = [RunPoint(manifest=f"r{i}", passed=int(r*100), total=100, pass_rate=r) for i, r in enumerate(rates)]
|
|
45
|
+
|
|
46
|
+
with mock.patch.object(RunTrendAnalyzer, "_load_run_point", side_effect=runs):
|
|
47
|
+
analyzer = RunTrendAnalyzer([Path("r0"), Path("r1"), Path("r2")])
|
|
48
|
+
report = analyzer.analyze()
|
|
49
|
+
|
|
50
|
+
self.assertEqual(report.direction, "stable")
|
|
51
|
+
self.assertAlmostEqual(report.pass_rate_slope, 0.0, places=4)
|
|
52
|
+
self.assertFalse(report.any_regression)
|
|
53
|
+
|
|
54
|
+
def test_analyzer_window_trimming(self) -> None:
|
|
55
|
+
paths = [Path(f"r{i}") for i in range(10)]
|
|
56
|
+
analyzer = RunTrendAnalyzer(paths, window=3)
|
|
57
|
+
self.assertEqual(len(analyzer.report_paths), 3)
|
|
58
|
+
self.assertEqual(analyzer.report_paths[-1].name, "r9")
|
|
59
|
+
|
|
60
|
+
def test_single_run_returns_stable(self) -> None:
|
|
61
|
+
runs = [RunPoint(manifest="r0", passed=5, total=10, pass_rate=0.5)]
|
|
62
|
+
with mock.patch.object(RunTrendAnalyzer, "_load_run_point", side_effect=runs):
|
|
63
|
+
analyzer = RunTrendAnalyzer([Path("r0")])
|
|
64
|
+
report = analyzer.analyze()
|
|
65
|
+
self.assertEqual(report.direction, "stable")
|
|
66
|
+
self.assertEqual(report.pass_rate_slope, 0.0)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class RunTrendAnalyzerFileTests(unittest.TestCase):
|
|
70
|
+
def test_load_run_point_from_file(self) -> None:
|
|
71
|
+
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as tmp:
|
|
72
|
+
data = {"passed": 8, "total": 10, "manifest": "test.yaml"}
|
|
73
|
+
json.dump(data, tmp)
|
|
74
|
+
tmp_path = Path(tmp.name)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
point = RunTrendAnalyzer._load_run_point(tmp_path)
|
|
78
|
+
self.assertIsNotNone(point)
|
|
79
|
+
self.assertEqual(point.passed, 8)
|
|
80
|
+
self.assertEqual(point.pass_rate, 0.8)
|
|
81
|
+
self.assertEqual(point.manifest, "test.yaml")
|
|
82
|
+
finally:
|
|
83
|
+
tmp_path.unlink()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
unittest.main()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.0"
|
|
File without changes
|
|
File without changes
|