ecp-runtime 0.3.2__tar.gz → 0.4.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.3.2 → ecp_runtime-0.4.0}/PKG-INFO +2 -2
- {ecp_runtime-0.3.2 → ecp_runtime-0.4.0}/README.md +1 -1
- {ecp_runtime-0.3.2 → ecp_runtime-0.4.0}/pyproject.toml +1 -1
- ecp_runtime-0.4.0/src/ecp_runtime/__init__.py +2 -0
- {ecp_runtime-0.3.2 → ecp_runtime-0.4.0}/src/ecp_runtime/cli.py +121 -32
- ecp_runtime-0.4.0/src/ecp_runtime/conformance.py +108 -0
- {ecp_runtime-0.3.2 → ecp_runtime-0.4.0}/src/ecp_runtime/manifest.py +15 -9
- {ecp_runtime-0.3.2 → ecp_runtime-0.4.0}/src/ecp_runtime/runner.py +86 -21
- {ecp_runtime-0.3.2 → ecp_runtime-0.4.0}/src/ecp_runtime/trend.py +13 -6
- {ecp_runtime-0.3.2 → ecp_runtime-0.4.0}/tests/test_cli.py +76 -0
- ecp_runtime-0.4.0/tests/test_conformance.py +91 -0
- {ecp_runtime-0.3.2 → ecp_runtime-0.4.0}/tests/test_example_integrations.py +56 -0
- {ecp_runtime-0.3.2 → ecp_runtime-0.4.0}/tests/test_manifest.py +22 -0
- {ecp_runtime-0.3.2 → ecp_runtime-0.4.0}/tests/test_runner.py +96 -1
- {ecp_runtime-0.3.2 → ecp_runtime-0.4.0}/tests/test_trend.py +3 -0
- ecp_runtime-0.3.2/src/ecp_runtime/__init__.py +0 -2
- {ecp_runtime-0.3.2 → ecp_runtime-0.4.0}/.gitignore +0 -0
- {ecp_runtime-0.3.2 → ecp_runtime-0.4.0}/src/ecp_runtime/graders.py +0 -0
- {ecp_runtime-0.3.2 → ecp_runtime-0.4.0}/src/ecp_runtime/reporter.py +0 -0
- {ecp_runtime-0.3.2 → ecp_runtime-0.4.0}/tests/__init__.py +0 -0
- {ecp_runtime-0.3.2 → ecp_runtime-0.4.0}/tests/test_graders.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ecp-runtime
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Vendor-neutral runtime for portable AI agent evaluations with outputs, tool calls, and audit context.
|
|
5
5
|
Project-URL: Homepage, https://github.com/evaluation-context-protocol/ecp
|
|
6
6
|
Project-URL: Documentation, https://evaluationcontextprotocol.io/
|
|
@@ -25,7 +25,7 @@ ECP is a vendor-neutral protocol for testing agent outputs, tool calls, and eval
|
|
|
25
25
|
## Install
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
pip install "ecp-runtime==0.
|
|
28
|
+
pip install "ecp-runtime==0.4.0"
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
## Usage
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ecp-runtime"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Vendor-neutral runtime for portable AI agent evaluations with outputs, tool calls, and audit context."
|
|
9
9
|
authors = [
|
|
10
10
|
{ name = "ECP Maintainers", email = "aniket.wattamwar17@gmail.com" },
|
|
@@ -5,14 +5,21 @@ import os
|
|
|
5
5
|
import shutil
|
|
6
6
|
import sys
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import Any, Dict, List, Optional
|
|
8
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
9
9
|
|
|
10
10
|
import typer
|
|
11
11
|
from pydantic import ValidationError
|
|
12
12
|
|
|
13
|
+
from .conformance import (
|
|
14
|
+
build_conformance_report,
|
|
15
|
+
conformance_check,
|
|
16
|
+
validate_initialize_result,
|
|
17
|
+
validate_reset_result,
|
|
18
|
+
validate_step_result,
|
|
19
|
+
)
|
|
13
20
|
from .manifest import ECPManifest
|
|
14
21
|
from .reporter import HTMLReporter
|
|
15
|
-
from .runner import ECPRunner
|
|
22
|
+
from .runner import ECPRunner, resolve_rpc_timeout
|
|
16
23
|
from .trend import RunTrendAnalyzer
|
|
17
24
|
|
|
18
25
|
app = typer.Typer(
|
|
@@ -78,6 +85,11 @@ def run(
|
|
|
78
85
|
"--fail-on-error/--no-fail-on-error",
|
|
79
86
|
help="Exit non-zero if any checks fail (useful for CI)",
|
|
80
87
|
),
|
|
88
|
+
timeout: Optional[float] = typer.Option(
|
|
89
|
+
None,
|
|
90
|
+
"--timeout",
|
|
91
|
+
help="RPC timeout in seconds (overrides ECP_RPC_TIMEOUT)",
|
|
92
|
+
),
|
|
81
93
|
):
|
|
82
94
|
"""
|
|
83
95
|
Execute an evaluation run based on a manifest file.
|
|
@@ -92,7 +104,7 @@ def run(
|
|
|
92
104
|
config = ECPManifest.from_yaml(str(manifest))
|
|
93
105
|
|
|
94
106
|
# Run the Tests
|
|
95
|
-
runner = ECPRunner(config)
|
|
107
|
+
runner = ECPRunner(config, rpc_timeout=timeout)
|
|
96
108
|
result_summary = runner.run_scenarios()
|
|
97
109
|
total = int(result_summary.get("total", 0) or 0)
|
|
98
110
|
passed = int(result_summary.get("passed", 0) or 0)
|
|
@@ -227,32 +239,96 @@ def conformance(
|
|
|
227
239
|
"--input",
|
|
228
240
|
help="Input text for the conformance step call",
|
|
229
241
|
),
|
|
242
|
+
json_out: Optional[Path] = typer.Option(
|
|
243
|
+
None,
|
|
244
|
+
"--json-out",
|
|
245
|
+
help="Path to save the machine-readable conformance report",
|
|
246
|
+
resolve_path=True,
|
|
247
|
+
),
|
|
248
|
+
print_json: bool = typer.Option(
|
|
249
|
+
False,
|
|
250
|
+
"--json",
|
|
251
|
+
help="Print only the machine-readable conformance report",
|
|
252
|
+
),
|
|
253
|
+
timeout: Optional[float] = typer.Option(
|
|
254
|
+
None,
|
|
255
|
+
"--timeout",
|
|
256
|
+
help="RPC timeout in seconds (overrides ECP_RPC_TIMEOUT)",
|
|
257
|
+
),
|
|
230
258
|
):
|
|
231
259
|
"""
|
|
232
|
-
|
|
260
|
+
Validate the core ECP protocol contract against an agent.
|
|
233
261
|
"""
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
262
|
+
rpc_timeout = resolve_rpc_timeout(timeout)
|
|
263
|
+
runner = ECPRunner(
|
|
264
|
+
type("Manifest", (), {"target": target, "scenarios": []})(),
|
|
265
|
+
rpc_timeout=rpc_timeout,
|
|
266
|
+
)
|
|
267
|
+
agent = runner._create_agent(target, rpc_timeout=rpc_timeout)
|
|
268
|
+
checks: List[Dict[str, Any]] = []
|
|
269
|
+
started = False
|
|
237
270
|
try:
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
271
|
+
agent.start()
|
|
272
|
+
started = True
|
|
273
|
+
initialize = _run_conformance_call(
|
|
274
|
+
agent,
|
|
275
|
+
"initialize response",
|
|
276
|
+
"agent/initialize",
|
|
277
|
+
{"config": {}},
|
|
278
|
+
result_validator=validate_initialize_result,
|
|
279
|
+
)
|
|
280
|
+
checks.append(initialize)
|
|
281
|
+
if initialize["passed"]:
|
|
282
|
+
checks.append(
|
|
283
|
+
_run_conformance_call(
|
|
284
|
+
agent,
|
|
285
|
+
"step result contract",
|
|
286
|
+
"agent/step",
|
|
287
|
+
{"input": step_input},
|
|
288
|
+
result_validator=validate_step_result,
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
else:
|
|
292
|
+
checks.append(_skipped_conformance_check("step result contract", "agent/step"))
|
|
249
293
|
except Exception as exc:
|
|
250
|
-
|
|
251
|
-
|
|
294
|
+
checks.append(
|
|
295
|
+
{
|
|
296
|
+
"name": "initialize response",
|
|
297
|
+
"method": "agent/initialize",
|
|
298
|
+
"passed": False,
|
|
299
|
+
"message": f"agent could not start: {exc}",
|
|
300
|
+
}
|
|
301
|
+
)
|
|
302
|
+
checks.append(_skipped_conformance_check("step result contract", "agent/step"))
|
|
252
303
|
finally:
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
304
|
+
if started:
|
|
305
|
+
checks.append(
|
|
306
|
+
_run_conformance_call(
|
|
307
|
+
agent,
|
|
308
|
+
"reset response",
|
|
309
|
+
"agent/reset",
|
|
310
|
+
{},
|
|
311
|
+
result_validator=validate_reset_result,
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
agent.stop()
|
|
315
|
+
else:
|
|
316
|
+
checks.append(_skipped_conformance_check("reset response", "agent/reset"))
|
|
317
|
+
|
|
318
|
+
report = build_conformance_report(target, checks)
|
|
319
|
+
rendered = json.dumps(report, indent=2)
|
|
320
|
+
if json_out:
|
|
321
|
+
json_out.write_text(rendered + "\n", encoding="utf-8")
|
|
322
|
+
if print_json:
|
|
323
|
+
typer.echo(rendered)
|
|
324
|
+
else:
|
|
325
|
+
for check in checks:
|
|
326
|
+
marker = "PASS" if check["passed"] else "FAIL"
|
|
327
|
+
typer.echo(f"{marker} | {check['name']} | {check['message']}")
|
|
328
|
+
typer.echo(f"Conformance: {report['passed']}/{report['total']} checks passed")
|
|
329
|
+
|
|
330
|
+
if not report["conformant"]:
|
|
331
|
+
raise typer.Exit(code=1)
|
|
256
332
|
|
|
257
333
|
|
|
258
334
|
@app.command()
|
|
@@ -316,15 +392,28 @@ def trend(
|
|
|
316
392
|
raise typer.Exit(code=2)
|
|
317
393
|
|
|
318
394
|
|
|
319
|
-
def
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
395
|
+
def _run_conformance_call(
|
|
396
|
+
agent: Any,
|
|
397
|
+
name: str,
|
|
398
|
+
method: str,
|
|
399
|
+
params: Dict[str, Any],
|
|
400
|
+
*,
|
|
401
|
+
result_validator: Optional[Callable[[Any], Any]] = None,
|
|
402
|
+
) -> Dict[str, Any]:
|
|
403
|
+
try:
|
|
404
|
+
response = agent.send_rpc(method, params)
|
|
405
|
+
except Exception as exc:
|
|
406
|
+
return {"name": name, "method": method, "passed": False, "message": str(exc)}
|
|
407
|
+
return conformance_check(name, method, response, result_validator=result_validator)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _skipped_conformance_check(name: str, method: str) -> Dict[str, Any]:
|
|
411
|
+
return {
|
|
412
|
+
"name": name,
|
|
413
|
+
"method": method,
|
|
414
|
+
"passed": False,
|
|
415
|
+
"message": "skipped because a prerequisite failed",
|
|
416
|
+
}
|
|
328
417
|
|
|
329
418
|
|
|
330
419
|
def _starter_agent() -> str:
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Protocol contract validation shared by runtime execution and conformance checks."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
VALID_STATUSES = {"done", "paused"}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def validate_rpc_response(response: Any, method: str) -> Any:
|
|
9
|
+
"""Validate the JSON-RPC response envelope and return its result."""
|
|
10
|
+
if not isinstance(response, dict):
|
|
11
|
+
raise ValueError(f"{method} response must be a JSON-RPC object")
|
|
12
|
+
if response.get("jsonrpc") != "2.0":
|
|
13
|
+
raise ValueError(f"{method} response must include jsonrpc='2.0'")
|
|
14
|
+
if "id" not in response:
|
|
15
|
+
raise ValueError(f"{method} response must include id")
|
|
16
|
+
if "result" in response and "error" in response:
|
|
17
|
+
raise ValueError(f"{method} response cannot include both result and error")
|
|
18
|
+
if "error" in response:
|
|
19
|
+
error = response["error"]
|
|
20
|
+
if not isinstance(error, dict):
|
|
21
|
+
raise ValueError(f"{method} error must be an object")
|
|
22
|
+
if not isinstance(error.get("code"), int) or not isinstance(error.get("message"), str):
|
|
23
|
+
raise ValueError(f"{method} error must include integer code and string message")
|
|
24
|
+
raise ValueError(f"{method} returned error: {error}")
|
|
25
|
+
if "result" not in response:
|
|
26
|
+
raise ValueError(f"{method} response must include result")
|
|
27
|
+
return response["result"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_step_result(result: Any) -> Dict[str, Any]:
|
|
31
|
+
"""Validate the normative agent/step result contract."""
|
|
32
|
+
if not isinstance(result, dict):
|
|
33
|
+
raise ValueError("agent/step result must be an object")
|
|
34
|
+
|
|
35
|
+
status = result.get("status")
|
|
36
|
+
if status not in VALID_STATUSES:
|
|
37
|
+
allowed = ", ".join(sorted(VALID_STATUSES))
|
|
38
|
+
raise ValueError(f"agent/step result status must be one of: {allowed}")
|
|
39
|
+
|
|
40
|
+
for field in ("public_output", "evaluation_context", "private_thought", "logs"):
|
|
41
|
+
value = result.get(field)
|
|
42
|
+
if value is not None and not isinstance(value, str):
|
|
43
|
+
raise ValueError(f"agent/step result {field} must be a string or null")
|
|
44
|
+
|
|
45
|
+
tool_calls = result.get("tool_calls")
|
|
46
|
+
if tool_calls is not None:
|
|
47
|
+
if not isinstance(tool_calls, list):
|
|
48
|
+
raise ValueError("agent/step result tool_calls must be an array or null")
|
|
49
|
+
for index, tool_call in enumerate(tool_calls):
|
|
50
|
+
_validate_tool_call(tool_call, index)
|
|
51
|
+
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def validate_initialize_result(result: Any) -> Dict[str, Any]:
|
|
56
|
+
if not isinstance(result, dict):
|
|
57
|
+
raise ValueError("agent/initialize result must be an object")
|
|
58
|
+
if not isinstance(result.get("name"), str) or not result["name"]:
|
|
59
|
+
raise ValueError("agent/initialize result name must be a non-empty string")
|
|
60
|
+
if not isinstance(result.get("capabilities"), dict):
|
|
61
|
+
raise ValueError("agent/initialize result capabilities must be an object")
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def validate_reset_result(result: Any) -> bool:
|
|
66
|
+
if result is not True:
|
|
67
|
+
raise ValueError("agent/reset result must be true")
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def conformance_check(
|
|
72
|
+
name: str,
|
|
73
|
+
method: str,
|
|
74
|
+
response: Any,
|
|
75
|
+
*,
|
|
76
|
+
result_validator: Optional[Callable[[Any], Any]] = None,
|
|
77
|
+
) -> Dict[str, Any]:
|
|
78
|
+
"""Return a stable, serializable result for one protocol check."""
|
|
79
|
+
try:
|
|
80
|
+
result = validate_rpc_response(response, method)
|
|
81
|
+
if result_validator:
|
|
82
|
+
result_validator(result)
|
|
83
|
+
except ValueError as exc:
|
|
84
|
+
return {"name": name, "method": method, "passed": False, "message": str(exc)}
|
|
85
|
+
return {"name": name, "method": method, "passed": True, "message": "passed"}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_conformance_report(target: str, checks: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
89
|
+
passed = sum(1 for check in checks if check["passed"])
|
|
90
|
+
total = len(checks)
|
|
91
|
+
return {
|
|
92
|
+
"target": target,
|
|
93
|
+
"conformant": passed == total,
|
|
94
|
+
"passed": passed,
|
|
95
|
+
"failed": total - passed,
|
|
96
|
+
"total": total,
|
|
97
|
+
"checks": checks,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _validate_tool_call(tool_call: Any, index: Optional[int] = None) -> None:
|
|
102
|
+
label = "tool call" if index is None else f"tool_calls[{index}]"
|
|
103
|
+
if not isinstance(tool_call, dict):
|
|
104
|
+
raise ValueError(f"agent/step result {label} must be an object")
|
|
105
|
+
if not isinstance(tool_call.get("name"), str) or not tool_call["name"]:
|
|
106
|
+
raise ValueError(f"agent/step result {label}.name must be a non-empty string")
|
|
107
|
+
if "arguments" in tool_call and not isinstance(tool_call["arguments"], dict):
|
|
108
|
+
raise ValueError(f"agent/step result {label}.arguments must be an object")
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
from typing import Any, Dict, List, Literal, Optional
|
|
2
2
|
|
|
3
3
|
import yaml
|
|
4
|
-
from pydantic import BaseModel, Field, model_validator
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class StrictModel(BaseModel):
|
|
8
|
+
"""Base class that keeps runtime validation aligned with the JSON schema."""
|
|
9
|
+
|
|
10
|
+
model_config = ConfigDict(extra="forbid")
|
|
5
11
|
|
|
6
12
|
|
|
7
13
|
# --- The Grader (Assertion) Schema ---
|
|
8
|
-
class GraderConfig(
|
|
14
|
+
class GraderConfig(StrictModel):
|
|
9
15
|
type: Literal["text_match", "llm_judge", "tool_usage"]
|
|
10
16
|
field: Literal["public_output", "evaluation_context", "private_thought"] = "public_output"
|
|
11
17
|
# For text_match
|
|
@@ -41,20 +47,20 @@ class GraderConfig(BaseModel):
|
|
|
41
47
|
return self
|
|
42
48
|
|
|
43
49
|
# --- The Step (Scenario) Schema ---
|
|
44
|
-
class StepConfig(
|
|
50
|
+
class StepConfig(StrictModel):
|
|
45
51
|
input: str
|
|
46
52
|
constraints: Dict[str, Any] = Field(default_factory=dict)
|
|
47
53
|
graders: List[GraderConfig] = Field(default_factory=list)
|
|
48
54
|
|
|
49
|
-
class ScenarioConfig(
|
|
50
|
-
name: str
|
|
55
|
+
class ScenarioConfig(StrictModel):
|
|
56
|
+
name: str = Field(min_length=1)
|
|
51
57
|
steps: List[StepConfig]
|
|
52
58
|
|
|
53
59
|
# --- The Root Manifest Schema ---
|
|
54
|
-
class ECPManifest(
|
|
55
|
-
manifest_version: Literal["v1"]
|
|
56
|
-
name: str
|
|
57
|
-
target: str
|
|
60
|
+
class ECPManifest(StrictModel):
|
|
61
|
+
manifest_version: Literal["v1"]
|
|
62
|
+
name: str = Field(min_length=1)
|
|
63
|
+
target: str = Field(min_length=1)
|
|
58
64
|
scenarios: List[ScenarioConfig]
|
|
59
65
|
|
|
60
66
|
@classmethod
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
Docstring for runtime.python.src.ecp_runtime.runner
|
|
3
3
|
|
|
4
4
|
Simplified Version. V0.1
|
|
5
|
-
AsyncIO Pending
|
|
6
5
|
"""
|
|
7
6
|
|
|
8
7
|
import json
|
|
9
8
|
import logging
|
|
9
|
+
import math
|
|
10
10
|
import os
|
|
11
11
|
import queue
|
|
12
12
|
import subprocess
|
|
@@ -17,6 +17,11 @@ from typing import Any, Dict, List, Optional
|
|
|
17
17
|
from urllib import error, request
|
|
18
18
|
from urllib.parse import urlparse
|
|
19
19
|
|
|
20
|
+
from .conformance import (
|
|
21
|
+
validate_initialize_result,
|
|
22
|
+
validate_rpc_response,
|
|
23
|
+
validate_step_result,
|
|
24
|
+
)
|
|
20
25
|
from .graders import evaluate_step
|
|
21
26
|
|
|
22
27
|
logger = logging.getLogger(__name__)
|
|
@@ -67,11 +72,12 @@ class AgentProcess:
|
|
|
67
72
|
if not params:
|
|
68
73
|
params = {}
|
|
69
74
|
|
|
75
|
+
request_id = int(time.time() * 1000)
|
|
70
76
|
request = {
|
|
71
77
|
"jsonrpc": "2.0",
|
|
72
78
|
"method": method,
|
|
73
79
|
"params": params,
|
|
74
|
-
"id":
|
|
80
|
+
"id": request_id
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
# Write to Agent's STDIN
|
|
@@ -79,7 +85,9 @@ class AgentProcess:
|
|
|
79
85
|
self.process.stdin.write(json_str + "\n")
|
|
80
86
|
self.process.stdin.flush()
|
|
81
87
|
|
|
82
|
-
|
|
88
|
+
response = self._read_json_response()
|
|
89
|
+
_ensure_response_id(response, request_id)
|
|
90
|
+
return response
|
|
83
91
|
|
|
84
92
|
def _read_json_response(self) -> Dict[str, Any]:
|
|
85
93
|
start_time = time.time()
|
|
@@ -168,11 +176,12 @@ class HTTPAgentClient:
|
|
|
168
176
|
if not params:
|
|
169
177
|
params = {}
|
|
170
178
|
|
|
179
|
+
request_id = int(time.time() * 1000)
|
|
171
180
|
payload = {
|
|
172
181
|
"jsonrpc": "2.0",
|
|
173
182
|
"method": method,
|
|
174
183
|
"params": params,
|
|
175
|
-
"id":
|
|
184
|
+
"id": request_id,
|
|
176
185
|
}
|
|
177
186
|
body = json.dumps(payload).encode("utf-8")
|
|
178
187
|
req = request.Request(
|
|
@@ -198,12 +207,15 @@ class HTTPAgentClient:
|
|
|
198
207
|
raise RuntimeError(f"HTTP RPC failed: {exc.reason}") from exc
|
|
199
208
|
|
|
200
209
|
if "text/event-stream" in content_type:
|
|
201
|
-
|
|
210
|
+
response = self._parse_sse_response(raw)
|
|
211
|
+
_ensure_response_id(response, request_id)
|
|
212
|
+
return response
|
|
202
213
|
if not raw:
|
|
203
214
|
raise RuntimeError("HTTP RPC response was empty")
|
|
204
215
|
payload = json.loads(raw)
|
|
205
216
|
if not isinstance(payload, dict):
|
|
206
217
|
raise RuntimeError("HTTP RPC response must be a JSON object")
|
|
218
|
+
_ensure_response_id(payload, request_id)
|
|
207
219
|
return payload
|
|
208
220
|
|
|
209
221
|
def _parse_sse_response(self, raw: str) -> Dict[str, Any]:
|
|
@@ -223,8 +235,9 @@ class HTTPAgentClient:
|
|
|
223
235
|
class ECPRunner:
|
|
224
236
|
"""The Orchestrator."""
|
|
225
237
|
|
|
226
|
-
def __init__(self, manifest):
|
|
238
|
+
def __init__(self, manifest, rpc_timeout: Optional[float] = None):
|
|
227
239
|
self.manifest = manifest
|
|
240
|
+
self.rpc_timeout = resolve_rpc_timeout(rpc_timeout)
|
|
228
241
|
|
|
229
242
|
def run_scenarios(self):
|
|
230
243
|
total_passed = 0
|
|
@@ -234,20 +247,35 @@ class ECPRunner:
|
|
|
234
247
|
for scenario in self.manifest.scenarios:
|
|
235
248
|
logger.info("Scenario: %s", scenario.name)
|
|
236
249
|
|
|
237
|
-
|
|
238
|
-
agent = self._create_agent(self.manifest.target, rpc_timeout=rpc_timeout)
|
|
250
|
+
agent = self._create_agent(self.manifest.target, rpc_timeout=self.rpc_timeout)
|
|
239
251
|
agent.start()
|
|
240
252
|
|
|
241
253
|
try:
|
|
242
|
-
init_resp =
|
|
243
|
-
|
|
254
|
+
init_resp = self._call_rpc(
|
|
255
|
+
agent,
|
|
256
|
+
"agent/initialize",
|
|
257
|
+
{"config": {}},
|
|
258
|
+
scenario.name,
|
|
259
|
+
step_idx=None,
|
|
260
|
+
)
|
|
261
|
+
try:
|
|
262
|
+
validate_initialize_result(init_resp["result"])
|
|
263
|
+
except ValueError as exc:
|
|
264
|
+
raise RuntimeError(
|
|
265
|
+
f"Invalid agent/initialize result at scenario='{scenario.name}': {exc}"
|
|
266
|
+
) from exc
|
|
244
267
|
scenario_steps: List[Dict[str, Any]] = []
|
|
245
268
|
|
|
246
269
|
for i, step in enumerate(scenario.steps):
|
|
247
270
|
# Execute
|
|
248
|
-
rpc_resp =
|
|
249
|
-
|
|
250
|
-
|
|
271
|
+
rpc_resp = self._call_rpc(
|
|
272
|
+
agent,
|
|
273
|
+
"agent/step",
|
|
274
|
+
{"input": step.input},
|
|
275
|
+
scenario.name,
|
|
276
|
+
step_idx=i + 1,
|
|
277
|
+
)
|
|
278
|
+
result_data = validate_step_result(rpc_resp["result"])
|
|
251
279
|
|
|
252
280
|
# Map to internal object
|
|
253
281
|
step_result = StepResult(
|
|
@@ -255,6 +283,7 @@ class ECPRunner:
|
|
|
255
283
|
public_output=result_data.get("public_output"),
|
|
256
284
|
evaluation_context=result_data.get("evaluation_context") or result_data.get("private_thought"),
|
|
257
285
|
private_thought=result_data.get("private_thought") or result_data.get("evaluation_context"),
|
|
286
|
+
logs=result_data.get("logs"),
|
|
258
287
|
tool_calls=result_data.get("tool_calls") if isinstance(result_data.get("tool_calls"), list) else None
|
|
259
288
|
)
|
|
260
289
|
|
|
@@ -282,6 +311,7 @@ class ECPRunner:
|
|
|
282
311
|
"output": step_result.public_output,
|
|
283
312
|
"evaluation_context": step_result.evaluation_context,
|
|
284
313
|
"tool_calls": step_result.tool_calls or [],
|
|
314
|
+
"logs": step_result.logs,
|
|
285
315
|
"checks": checks
|
|
286
316
|
})
|
|
287
317
|
|
|
@@ -312,20 +342,55 @@ class ECPRunner:
|
|
|
312
342
|
step_idx: Optional[int],
|
|
313
343
|
method: str,
|
|
314
344
|
) -> None:
|
|
315
|
-
|
|
316
|
-
|
|
345
|
+
where = f"scenario='{scenario_name}'"
|
|
346
|
+
if step_idx is not None:
|
|
347
|
+
where += f", step={step_idx}"
|
|
348
|
+
try:
|
|
349
|
+
validate_rpc_response(rpc_resp, method)
|
|
350
|
+
except ValueError as exc:
|
|
351
|
+
raise RuntimeError(f"RPC call failed ({method}) at {where}: {exc}") from exc
|
|
317
352
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
353
|
+
def _call_rpc(
|
|
354
|
+
self,
|
|
355
|
+
agent: Any,
|
|
356
|
+
method: str,
|
|
357
|
+
params: Dict[str, Any],
|
|
358
|
+
scenario_name: str,
|
|
359
|
+
step_idx: Optional[int],
|
|
360
|
+
) -> Dict[str, Any]:
|
|
321
361
|
where = f"scenario='{scenario_name}'"
|
|
322
362
|
if step_idx is not None:
|
|
323
363
|
where += f", step={step_idx}"
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
364
|
+
try:
|
|
365
|
+
response = agent.send_rpc(method, params)
|
|
366
|
+
except Exception as exc:
|
|
367
|
+
raise RuntimeError(f"RPC call failed ({method}) at {where}: {exc}") from exc
|
|
368
|
+
self._ensure_rpc_success(response, scenario_name, step_idx, method)
|
|
369
|
+
return response
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def resolve_rpc_timeout(value: Optional[float] = None) -> float:
|
|
373
|
+
"""Resolve and validate an explicit timeout or the ECP_RPC_TIMEOUT fallback."""
|
|
374
|
+
raw_value: Any = value if value is not None else os.environ.get("ECP_RPC_TIMEOUT", "30")
|
|
375
|
+
try:
|
|
376
|
+
timeout = float(raw_value)
|
|
377
|
+
except (TypeError, ValueError) as exc:
|
|
378
|
+
raise ValueError(
|
|
379
|
+
f"RPC timeout must be a positive number; received {raw_value!r}"
|
|
380
|
+
) from exc
|
|
381
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
382
|
+
raise ValueError(f"RPC timeout must be a positive finite number; received {raw_value!r}")
|
|
383
|
+
return timeout
|
|
327
384
|
|
|
328
385
|
|
|
329
386
|
def _is_http_url(target: str) -> bool:
|
|
330
387
|
parsed = urlparse(target)
|
|
331
388
|
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _ensure_response_id(response: Dict[str, Any], request_id: int) -> None:
|
|
392
|
+
response_id = response.get("id")
|
|
393
|
+
if type(response_id) is not type(request_id) or response_id != request_id:
|
|
394
|
+
raise RuntimeError(
|
|
395
|
+
f"JSON-RPC response id mismatch: expected {request_id}, got {response_id}"
|
|
396
|
+
)
|
|
@@ -8,10 +8,9 @@ RunTrendAnalyzer - reads a sequence of saved JSON report files produced by
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import json
|
|
11
|
-
import statistics
|
|
12
11
|
from dataclasses import dataclass, field
|
|
13
12
|
from pathlib import Path
|
|
14
|
-
from typing import List, Literal
|
|
13
|
+
from typing import List, Literal, Optional
|
|
15
14
|
|
|
16
15
|
|
|
17
16
|
@dataclass
|
|
@@ -81,7 +80,7 @@ class RunTrendAnalyzer:
|
|
|
81
80
|
)
|
|
82
81
|
|
|
83
82
|
@staticmethod
|
|
84
|
-
def _load_run_point(path: Path) -> RunPoint
|
|
83
|
+
def _load_run_point(path: Path) -> Optional[RunPoint]:
|
|
85
84
|
try:
|
|
86
85
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
87
86
|
except (OSError, json.JSONDecodeError):
|
|
@@ -101,9 +100,17 @@ class RunTrendAnalyzer:
|
|
|
101
100
|
|
|
102
101
|
@staticmethod
|
|
103
102
|
def _compute_slope(pass_rates: List[float]) -> float:
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
103
|
+
count = len(pass_rates)
|
|
104
|
+
if count < 2:
|
|
105
|
+
return 0.0
|
|
106
|
+
mean_x = (count - 1) / 2
|
|
107
|
+
mean_y = sum(pass_rates) / count
|
|
108
|
+
numerator = sum(
|
|
109
|
+
(index - mean_x) * (rate - mean_y)
|
|
110
|
+
for index, rate in enumerate(pass_rates)
|
|
111
|
+
)
|
|
112
|
+
denominator = sum((index - mean_x) ** 2 for index in range(count))
|
|
113
|
+
return numerator / denominator if denominator else 0.0
|
|
107
114
|
|
|
108
115
|
def _classify(self, slope: float) -> Literal["improving", "degrading", "stable"]:
|
|
109
116
|
if slope > self._IMPROVING_THRESHOLD:
|
|
@@ -9,6 +9,7 @@ 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
|
+
import ecp_runtime.cli as cli_module
|
|
12
13
|
from ecp_runtime.cli import app
|
|
13
14
|
from typer.testing import CliRunner
|
|
14
15
|
|
|
@@ -72,6 +73,26 @@ class CLISmokeTests(unittest.TestCase):
|
|
|
72
73
|
result = self.runner.invoke(app, ["run", "--manifest", self.manifest_path, "--no-fail-on-error"])
|
|
73
74
|
self.assertEqual(result.exit_code, 0, msg=result.output)
|
|
74
75
|
|
|
76
|
+
def test_run_passes_explicit_timeout_to_runner(self) -> None:
|
|
77
|
+
fake_config = object()
|
|
78
|
+
runtime_class = mock.Mock(
|
|
79
|
+
return_value=mock.Mock(
|
|
80
|
+
run_scenarios=mock.Mock(return_value={"passed": 0, "total": 0, "scenarios": []})
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
with mock.patch("ecp_runtime.cli._configure_logging"), mock.patch.object(
|
|
84
|
+
cli_module.ECPManifest,
|
|
85
|
+
"from_yaml",
|
|
86
|
+
return_value=fake_config,
|
|
87
|
+
), mock.patch("ecp_runtime.cli.ECPRunner", runtime_class):
|
|
88
|
+
result = self.runner.invoke(
|
|
89
|
+
app,
|
|
90
|
+
["run", "--manifest", self.manifest_path, "--timeout", "4.5"],
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
self.assertEqual(result.exit_code, 0, msg=result.output)
|
|
94
|
+
runtime_class.assert_called_once_with(fake_config, rpc_timeout=4.5)
|
|
95
|
+
|
|
75
96
|
def test_validate_command(self) -> None:
|
|
76
97
|
result = self.runner.invoke(app, ["validate", self.manifest_path])
|
|
77
98
|
|
|
@@ -95,6 +116,61 @@ class CLISmokeTests(unittest.TestCase):
|
|
|
95
116
|
self.assertTrue(manifest.exists())
|
|
96
117
|
self.assertIn("evaluation_context", manifest.read_text(encoding="utf-8"))
|
|
97
118
|
|
|
119
|
+
def test_conformance_json_report(self) -> None:
|
|
120
|
+
fake_agent = mock.Mock()
|
|
121
|
+
fake_agent.send_rpc.side_effect = [
|
|
122
|
+
{"jsonrpc": "2.0", "id": 1, "result": {"name": "test", "capabilities": {}}},
|
|
123
|
+
{"jsonrpc": "2.0", "id": 2, "result": {"status": "done", "public_output": "ok"}},
|
|
124
|
+
{"jsonrpc": "2.0", "id": 3, "result": True},
|
|
125
|
+
]
|
|
126
|
+
fake_runtime = mock.Mock()
|
|
127
|
+
fake_runtime._create_agent.return_value = fake_agent
|
|
128
|
+
|
|
129
|
+
with mock.patch("ecp_runtime.cli.ECPRunner", return_value=fake_runtime):
|
|
130
|
+
result = self.runner.invoke(app, ["conformance", "--target", "python agent.py", "--json"])
|
|
131
|
+
|
|
132
|
+
self.assertEqual(result.exit_code, 0, msg=result.output)
|
|
133
|
+
payload = json.loads(result.stdout)
|
|
134
|
+
self.assertTrue(payload["conformant"])
|
|
135
|
+
self.assertEqual(payload["total"], 3)
|
|
136
|
+
fake_agent.stop.assert_called_once()
|
|
137
|
+
|
|
138
|
+
def test_conformance_passes_explicit_timeout_to_transport(self) -> None:
|
|
139
|
+
fake_agent = mock.Mock()
|
|
140
|
+
fake_agent.send_rpc.side_effect = [
|
|
141
|
+
{"jsonrpc": "2.0", "id": 1, "result": {"name": "test", "capabilities": {}}},
|
|
142
|
+
{"jsonrpc": "2.0", "id": 2, "result": {"status": "done"}},
|
|
143
|
+
{"jsonrpc": "2.0", "id": 3, "result": True},
|
|
144
|
+
]
|
|
145
|
+
fake_runtime = mock.Mock()
|
|
146
|
+
fake_runtime._create_agent.return_value = fake_agent
|
|
147
|
+
runtime_class = mock.Mock(return_value=fake_runtime)
|
|
148
|
+
|
|
149
|
+
with mock.patch("ecp_runtime.cli.ECPRunner", runtime_class):
|
|
150
|
+
result = self.runner.invoke(
|
|
151
|
+
app,
|
|
152
|
+
["conformance", "--target", "python agent.py", "--timeout", "6", "--json"],
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
self.assertEqual(result.exit_code, 0, msg=result.output)
|
|
156
|
+
fake_runtime._create_agent.assert_called_once_with("python agent.py", rpc_timeout=6.0)
|
|
157
|
+
|
|
158
|
+
def test_conformance_rejects_invalid_step_result(self) -> None:
|
|
159
|
+
fake_agent = mock.Mock()
|
|
160
|
+
fake_agent.send_rpc.side_effect = [
|
|
161
|
+
{"jsonrpc": "2.0", "id": 1, "result": {}},
|
|
162
|
+
{"jsonrpc": "2.0", "id": 2, "result": {"status": "invalid"}},
|
|
163
|
+
{"jsonrpc": "2.0", "id": 3, "result": True},
|
|
164
|
+
]
|
|
165
|
+
fake_runtime = mock.Mock()
|
|
166
|
+
fake_runtime._create_agent.return_value = fake_agent
|
|
167
|
+
|
|
168
|
+
with mock.patch("ecp_runtime.cli.ECPRunner", return_value=fake_runtime):
|
|
169
|
+
result = self.runner.invoke(app, ["conformance", "--target", "python agent.py"])
|
|
170
|
+
|
|
171
|
+
self.assertEqual(result.exit_code, 1, msg=result.output)
|
|
172
|
+
self.assertIn("FAIL | step result contract", result.output)
|
|
173
|
+
|
|
98
174
|
|
|
99
175
|
if __name__ == "__main__":
|
|
100
176
|
unittest.main()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import unittest
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
RUNTIME_SRC = Path(__file__).resolve().parents[1] / "src"
|
|
6
|
+
if str(RUNTIME_SRC) not in sys.path:
|
|
7
|
+
sys.path.insert(0, str(RUNTIME_SRC))
|
|
8
|
+
|
|
9
|
+
from ecp_runtime.conformance import (
|
|
10
|
+
build_conformance_report,
|
|
11
|
+
conformance_check,
|
|
12
|
+
validate_initialize_result,
|
|
13
|
+
validate_reset_result,
|
|
14
|
+
validate_rpc_response,
|
|
15
|
+
validate_step_result,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConformanceTests(unittest.TestCase):
|
|
20
|
+
def test_valid_step_result(self) -> None:
|
|
21
|
+
result = {
|
|
22
|
+
"status": "done",
|
|
23
|
+
"public_output": "ok",
|
|
24
|
+
"evaluation_context": "verified",
|
|
25
|
+
"tool_calls": [{"name": "lookup", "arguments": {"id": 1}}],
|
|
26
|
+
"logs": "complete",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
self.assertIs(validate_step_result(result), result)
|
|
30
|
+
|
|
31
|
+
def test_initialize_contract(self) -> None:
|
|
32
|
+
result = {"name": "agent", "capabilities": {}}
|
|
33
|
+
self.assertIs(validate_initialize_result(result), result)
|
|
34
|
+
|
|
35
|
+
with self.assertRaisesRegex(ValueError, "capabilities"):
|
|
36
|
+
validate_initialize_result({"name": "agent"})
|
|
37
|
+
|
|
38
|
+
def test_reset_contract(self) -> None:
|
|
39
|
+
self.assertTrue(validate_reset_result(True))
|
|
40
|
+
with self.assertRaisesRegex(ValueError, "must be true"):
|
|
41
|
+
validate_reset_result(None)
|
|
42
|
+
|
|
43
|
+
def test_invalid_status_is_rejected(self) -> None:
|
|
44
|
+
with self.assertRaisesRegex(ValueError, "status"):
|
|
45
|
+
validate_step_result({"status": "complete"})
|
|
46
|
+
|
|
47
|
+
def test_invalid_tool_call_is_rejected(self) -> None:
|
|
48
|
+
with self.assertRaisesRegex(ValueError, "name"):
|
|
49
|
+
validate_step_result({"status": "done", "tool_calls": [{"arguments": {}}]})
|
|
50
|
+
|
|
51
|
+
def test_rpc_envelope_requires_id(self) -> None:
|
|
52
|
+
with self.assertRaisesRegex(ValueError, "include id"):
|
|
53
|
+
validate_rpc_response({"jsonrpc": "2.0", "result": {}}, "agent/initialize")
|
|
54
|
+
|
|
55
|
+
def test_rpc_envelope_rejects_result_and_error(self) -> None:
|
|
56
|
+
with self.assertRaisesRegex(ValueError, "both result and error"):
|
|
57
|
+
validate_rpc_response(
|
|
58
|
+
{
|
|
59
|
+
"jsonrpc": "2.0",
|
|
60
|
+
"id": 1,
|
|
61
|
+
"result": {},
|
|
62
|
+
"error": {"code": -32000, "message": "failure"},
|
|
63
|
+
},
|
|
64
|
+
"agent/initialize",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def test_report_has_stable_counts(self) -> None:
|
|
68
|
+
checks = [
|
|
69
|
+
conformance_check(
|
|
70
|
+
"initialize",
|
|
71
|
+
"agent/initialize",
|
|
72
|
+
{"jsonrpc": "2.0", "id": 1, "result": {}},
|
|
73
|
+
),
|
|
74
|
+
conformance_check(
|
|
75
|
+
"step",
|
|
76
|
+
"agent/step",
|
|
77
|
+
{"jsonrpc": "2.0", "id": 2, "result": {"status": "invalid"}},
|
|
78
|
+
result_validator=validate_step_result,
|
|
79
|
+
),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
report = build_conformance_report("python agent.py", checks)
|
|
83
|
+
|
|
84
|
+
self.assertFalse(report["conformant"])
|
|
85
|
+
self.assertEqual(report["passed"], 1)
|
|
86
|
+
self.assertEqual(report["failed"], 1)
|
|
87
|
+
self.assertEqual(report["total"], 2)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
unittest.main()
|
|
@@ -50,6 +50,62 @@ class ExampleIntegrationTests(unittest.TestCase):
|
|
|
50
50
|
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
|
51
51
|
self.assertIn('"failed": 0', result.stdout)
|
|
52
52
|
|
|
53
|
+
def test_async_python_demo_manifest_passes(self) -> None:
|
|
54
|
+
manifest = REPO_ROOT / "examples" / "async_python_demo" / "manifest.yaml"
|
|
55
|
+
result = self._run_manifest(manifest)
|
|
56
|
+
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
|
57
|
+
self.assertIn('"failed": 0', result.stdout)
|
|
58
|
+
|
|
59
|
+
def test_async_python_demo_conforms_over_http(self) -> None:
|
|
60
|
+
port = self._free_port()
|
|
61
|
+
env = dict(os.environ)
|
|
62
|
+
existing = env.get("PYTHONPATH", "")
|
|
63
|
+
extra_paths = [str(RUNTIME_SRC), str(REPO_ROOT / "sdk" / "python" / "src")]
|
|
64
|
+
if existing:
|
|
65
|
+
extra_paths.append(existing)
|
|
66
|
+
env["PYTHONPATH"] = os.pathsep.join(extra_paths)
|
|
67
|
+
env["ECP_TRANSPORT"] = "http"
|
|
68
|
+
env["ECP_HTTP_PORT"] = str(port)
|
|
69
|
+
|
|
70
|
+
server = subprocess.Popen(
|
|
71
|
+
[sys.executable, str(REPO_ROOT / "examples" / "async_python_demo" / "agent.py")],
|
|
72
|
+
cwd=REPO_ROOT,
|
|
73
|
+
text=True,
|
|
74
|
+
stdout=subprocess.PIPE,
|
|
75
|
+
stderr=subprocess.PIPE,
|
|
76
|
+
env=env,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
self._wait_for_port("127.0.0.1", port)
|
|
81
|
+
result = subprocess.run(
|
|
82
|
+
[
|
|
83
|
+
sys.executable,
|
|
84
|
+
"-m",
|
|
85
|
+
"ecp_runtime.cli",
|
|
86
|
+
"conformance",
|
|
87
|
+
"--target",
|
|
88
|
+
f"http://127.0.0.1:{port}/ecp",
|
|
89
|
+
"--timeout",
|
|
90
|
+
"5",
|
|
91
|
+
"--json",
|
|
92
|
+
],
|
|
93
|
+
cwd=REPO_ROOT,
|
|
94
|
+
text=True,
|
|
95
|
+
capture_output=True,
|
|
96
|
+
env=env,
|
|
97
|
+
check=False,
|
|
98
|
+
)
|
|
99
|
+
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
|
100
|
+
self.assertIn('"conformant": true', result.stdout)
|
|
101
|
+
finally:
|
|
102
|
+
server.terminate()
|
|
103
|
+
try:
|
|
104
|
+
server.communicate(timeout=2)
|
|
105
|
+
except subprocess.TimeoutExpired:
|
|
106
|
+
server.kill()
|
|
107
|
+
server.communicate()
|
|
108
|
+
|
|
53
109
|
def test_streamable_http_demo_manifest_passes(self) -> None:
|
|
54
110
|
port = self._free_port()
|
|
55
111
|
server_env = dict(os.environ)
|
|
@@ -29,6 +29,9 @@ class ManifestValidationTests(unittest.TestCase):
|
|
|
29
29
|
with self.assertRaises(ValidationError):
|
|
30
30
|
GraderConfig(type="llm_judge")
|
|
31
31
|
|
|
32
|
+
with self.assertRaises(ValidationError):
|
|
33
|
+
GraderConfig(type="llm_judge", prompt=" ")
|
|
34
|
+
|
|
32
35
|
def test_invalid_grader_type_rejected(self) -> None:
|
|
33
36
|
with self.assertRaises(ValidationError):
|
|
34
37
|
GraderConfig(type="unknown") # type: ignore[arg-type]
|
|
@@ -51,6 +54,25 @@ class ManifestValidationTests(unittest.TestCase):
|
|
|
51
54
|
with self.assertRaises(ValueError):
|
|
52
55
|
ECPManifest.from_yaml(tmp_path)
|
|
53
56
|
|
|
57
|
+
def test_unknown_manifest_fields_are_rejected(self) -> None:
|
|
58
|
+
with self.assertRaises(ValidationError):
|
|
59
|
+
ECPManifest(
|
|
60
|
+
manifest_version="v1",
|
|
61
|
+
name="test",
|
|
62
|
+
target="python agent.py",
|
|
63
|
+
scenarios=[],
|
|
64
|
+
unexpected=True,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def test_unknown_grader_fields_are_rejected(self) -> None:
|
|
68
|
+
with self.assertRaises(ValidationError):
|
|
69
|
+
GraderConfig(
|
|
70
|
+
type="text_match",
|
|
71
|
+
condition="contains",
|
|
72
|
+
value="ok",
|
|
73
|
+
unexpected=True,
|
|
74
|
+
)
|
|
75
|
+
|
|
54
76
|
|
|
55
77
|
if __name__ == "__main__":
|
|
56
78
|
unittest.main()
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import sys
|
|
2
3
|
import unittest
|
|
3
4
|
from pathlib import Path
|
|
@@ -9,7 +10,12 @@ if str(RUNTIME_SRC) not in sys.path:
|
|
|
9
10
|
sys.path.insert(0, str(RUNTIME_SRC))
|
|
10
11
|
|
|
11
12
|
from ecp_runtime.manifest import StepConfig
|
|
12
|
-
from ecp_runtime.runner import
|
|
13
|
+
from ecp_runtime.runner import (
|
|
14
|
+
ECPRunner,
|
|
15
|
+
HTTPAgentClient,
|
|
16
|
+
_ensure_response_id,
|
|
17
|
+
resolve_rpc_timeout,
|
|
18
|
+
)
|
|
13
19
|
|
|
14
20
|
|
|
15
21
|
class RunnerTests(unittest.TestCase):
|
|
@@ -73,6 +79,24 @@ class RunnerTests(unittest.TestCase):
|
|
|
73
79
|
self.assertIn("step=1", msg)
|
|
74
80
|
self.assertIn("boom", msg)
|
|
75
81
|
|
|
82
|
+
def test_runner_rejects_invalid_initialize_result(self) -> None:
|
|
83
|
+
class FakeAgentProcess:
|
|
84
|
+
def __init__(self, command, rpc_timeout=30.0):
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
def start(self):
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
def stop(self):
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
def send_rpc(self, method, params=None):
|
|
94
|
+
return {"jsonrpc": "2.0", "id": 1, "result": {"name": "missing-capabilities"}}
|
|
95
|
+
|
|
96
|
+
with mock.patch("ecp_runtime.runner.AgentProcess", FakeAgentProcess):
|
|
97
|
+
with self.assertRaisesRegex(RuntimeError, "Invalid agent/initialize result"):
|
|
98
|
+
ECPRunner(self._manifest()).run_scenarios()
|
|
99
|
+
|
|
76
100
|
def test_runner_uses_http_client_for_url_target(self) -> None:
|
|
77
101
|
runner = ECPRunner(SimpleNamespace(target="http://127.0.0.1:8765/ecp", scenarios=[]))
|
|
78
102
|
|
|
@@ -82,6 +106,66 @@ class RunnerTests(unittest.TestCase):
|
|
|
82
106
|
self.assertEqual(agent.endpoint, "http://127.0.0.1:8765/ecp")
|
|
83
107
|
self.assertEqual(agent.rpc_timeout, 12.0)
|
|
84
108
|
|
|
109
|
+
def test_runner_passes_explicit_timeout_to_agent(self) -> None:
|
|
110
|
+
observed = {}
|
|
111
|
+
|
|
112
|
+
class FakeAgentProcess:
|
|
113
|
+
def __init__(self, command, rpc_timeout=30.0):
|
|
114
|
+
observed["timeout"] = rpc_timeout
|
|
115
|
+
|
|
116
|
+
def start(self):
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
def stop(self):
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
def send_rpc(self, method, params=None):
|
|
123
|
+
if method == "agent/initialize":
|
|
124
|
+
return {"jsonrpc": "2.0", "id": 1, "result": {"name": "x", "capabilities": {}}}
|
|
125
|
+
return {"jsonrpc": "2.0", "id": 2, "result": {"status": "done"}}
|
|
126
|
+
|
|
127
|
+
with mock.patch("ecp_runtime.runner.AgentProcess", FakeAgentProcess):
|
|
128
|
+
ECPRunner(self._manifest(), rpc_timeout=4.25).run_scenarios()
|
|
129
|
+
|
|
130
|
+
self.assertEqual(observed["timeout"], 4.25)
|
|
131
|
+
|
|
132
|
+
def test_transport_timeout_includes_method_and_step_context(self) -> None:
|
|
133
|
+
class FakeAgentProcess:
|
|
134
|
+
def __init__(self, command, rpc_timeout=30.0):
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
def start(self):
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
def stop(self):
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
def send_rpc(self, method, params=None):
|
|
144
|
+
if method == "agent/initialize":
|
|
145
|
+
return {"jsonrpc": "2.0", "id": 1, "result": {"name": "x", "capabilities": {}}}
|
|
146
|
+
raise RuntimeError("Agent response timed out after 0.1s")
|
|
147
|
+
|
|
148
|
+
with mock.patch("ecp_runtime.runner.AgentProcess", FakeAgentProcess):
|
|
149
|
+
with self.assertRaises(RuntimeError) as ctx:
|
|
150
|
+
ECPRunner(self._manifest(), rpc_timeout=0.1).run_scenarios()
|
|
151
|
+
|
|
152
|
+
message = str(ctx.exception)
|
|
153
|
+
self.assertIn("agent/step", message)
|
|
154
|
+
self.assertIn("Scenario A", message)
|
|
155
|
+
self.assertIn("step=1", message)
|
|
156
|
+
self.assertIn("timed out", message)
|
|
157
|
+
|
|
158
|
+
def test_timeout_resolution_prefers_explicit_value_and_validates_environment(self) -> None:
|
|
159
|
+
with mock.patch.dict("os.environ", {"ECP_RPC_TIMEOUT": "8.5"}):
|
|
160
|
+
self.assertEqual(resolve_rpc_timeout(), 8.5)
|
|
161
|
+
self.assertEqual(resolve_rpc_timeout(2), 2.0)
|
|
162
|
+
|
|
163
|
+
for invalid in ("invalid", "0", "-1", "inf", "nan"):
|
|
164
|
+
with self.subTest(invalid=invalid):
|
|
165
|
+
with mock.patch.dict("os.environ", {"ECP_RPC_TIMEOUT": invalid}):
|
|
166
|
+
with self.assertRaisesRegex(ValueError, "positive"):
|
|
167
|
+
resolve_rpc_timeout()
|
|
168
|
+
|
|
85
169
|
def test_http_agent_client_posts_json_rpc(self) -> None:
|
|
86
170
|
class FakeResponse:
|
|
87
171
|
headers = {"Content-Type": "application/json; charset=utf-8"}
|
|
@@ -102,6 +186,10 @@ class RunnerTests(unittest.TestCase):
|
|
|
102
186
|
captured["timeout"] = timeout
|
|
103
187
|
captured["body"] = req.data
|
|
104
188
|
captured["accept"] = req.headers.get("Accept")
|
|
189
|
+
request_id = json.loads(req.data.decode("utf-8"))["id"]
|
|
190
|
+
FakeResponse.read = lambda self: json.dumps(
|
|
191
|
+
{"jsonrpc": "2.0", "id": request_id, "result": {"ok": True}}
|
|
192
|
+
).encode("utf-8")
|
|
105
193
|
return FakeResponse()
|
|
106
194
|
|
|
107
195
|
with mock.patch("ecp_runtime.runner.request.urlopen", fake_urlopen):
|
|
@@ -117,6 +205,13 @@ class RunnerTests(unittest.TestCase):
|
|
|
117
205
|
self.assertIn('"method": "agent/step"', body)
|
|
118
206
|
self.assertIn('"input": "hi"', body)
|
|
119
207
|
|
|
208
|
+
def test_response_id_must_match_request(self) -> None:
|
|
209
|
+
with self.assertRaisesRegex(RuntimeError, "id mismatch"):
|
|
210
|
+
_ensure_response_id({"jsonrpc": "2.0", "id": 2, "result": {}}, 1)
|
|
211
|
+
|
|
212
|
+
with self.assertRaisesRegex(RuntimeError, "id mismatch"):
|
|
213
|
+
_ensure_response_id({"jsonrpc": "2.0", "id": True, "result": {}}, 1)
|
|
214
|
+
|
|
120
215
|
|
|
121
216
|
if __name__ == "__main__":
|
|
122
217
|
unittest.main()
|
|
@@ -65,6 +65,9 @@ class RunTrendAnalyzerTests(unittest.TestCase):
|
|
|
65
65
|
self.assertEqual(report.direction, "stable")
|
|
66
66
|
self.assertEqual(report.pass_rate_slope, 0.0)
|
|
67
67
|
|
|
68
|
+
def test_slope_matches_expected_least_squares_result(self) -> None:
|
|
69
|
+
self.assertAlmostEqual(RunTrendAnalyzer._compute_slope([0.1, 0.5, 0.9]), 0.4)
|
|
70
|
+
|
|
68
71
|
|
|
69
72
|
class RunTrendAnalyzerFileTests(unittest.TestCase):
|
|
70
73
|
def test_load_run_point_from_file(self) -> None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|