ecp-runtime 0.3.3__tar.gz → 0.5.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.3 → ecp_runtime-0.5.0}/.gitignore +4 -0
- {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/PKG-INFO +2 -2
- {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/README.md +1 -1
- {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/pyproject.toml +4 -1
- ecp_runtime-0.5.0/src/ecp_runtime/__init__.py +2 -0
- {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/src/ecp_runtime/cli.py +152 -33
- ecp_runtime-0.5.0/src/ecp_runtime/conformance.py +108 -0
- {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/src/ecp_runtime/graders.py +9 -3
- ecp_runtime-0.5.0/src/ecp_runtime/manifest.py +129 -0
- ecp_runtime-0.5.0/src/ecp_runtime/pytest_plugin.py +76 -0
- {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/src/ecp_runtime/runner.py +94 -22
- {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/src/ecp_runtime/trend.py +13 -6
- {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/tests/test_cli.py +76 -0
- ecp_runtime-0.5.0/tests/test_conformance.py +91 -0
- {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/tests/test_example_integrations.py +56 -0
- {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/tests/test_manifest.py +22 -0
- {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/tests/test_runner.py +96 -1
- {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/tests/test_trend.py +3 -0
- ecp_runtime-0.3.3/src/ecp_runtime/__init__.py +0 -2
- ecp_runtime-0.3.3/src/ecp_runtime/manifest.py +0 -66
- {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/src/ecp_runtime/reporter.py +0 -0
- {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/tests/__init__.py +0 -0
- {ecp_runtime-0.3.3 → ecp_runtime-0.5.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.5.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.5.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.5.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" },
|
|
@@ -29,4 +29,7 @@ Issues = "https://github.com/evaluation-context-protocol/ecp/issues"
|
|
|
29
29
|
[project.scripts]
|
|
30
30
|
ecp = "ecp_runtime.cli:app"
|
|
31
31
|
|
|
32
|
+
[project.entry-points.pytest11]
|
|
33
|
+
ecp_runtime = "ecp_runtime.pytest_plugin"
|
|
34
|
+
|
|
32
35
|
|
|
@@ -1,18 +1,26 @@
|
|
|
1
|
-
|
|
1
|
+
import glob
|
|
2
2
|
import json
|
|
3
3
|
import logging
|
|
4
4
|
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
|
+
# Import local modules (Using relative imports)
|
|
14
|
+
from .conformance import (
|
|
15
|
+
build_conformance_report,
|
|
16
|
+
conformance_check,
|
|
17
|
+
validate_initialize_result,
|
|
18
|
+
validate_reset_result,
|
|
19
|
+
validate_step_result,
|
|
20
|
+
)
|
|
13
21
|
from .manifest import ECPManifest
|
|
14
22
|
from .reporter import HTMLReporter
|
|
15
|
-
from .runner import ECPRunner
|
|
23
|
+
from .runner import ECPRunner, resolve_rpc_timeout
|
|
16
24
|
from .trend import RunTrendAnalyzer
|
|
17
25
|
|
|
18
26
|
app = typer.Typer(
|
|
@@ -73,11 +81,21 @@ def run(
|
|
|
73
81
|
"--json",
|
|
74
82
|
help="Print the JSON report to stdout",
|
|
75
83
|
),
|
|
84
|
+
export: Optional[str] = typer.Option(
|
|
85
|
+
None,
|
|
86
|
+
"--export",
|
|
87
|
+
help="Export evaluation results to an external platform (e.g., langsmith)",
|
|
88
|
+
),
|
|
76
89
|
fail_on_error: bool = typer.Option(
|
|
77
90
|
True,
|
|
78
91
|
"--fail-on-error/--no-fail-on-error",
|
|
79
92
|
help="Exit non-zero if any checks fail (useful for CI)",
|
|
80
93
|
),
|
|
94
|
+
timeout: Optional[float] = typer.Option(
|
|
95
|
+
None,
|
|
96
|
+
"--timeout",
|
|
97
|
+
help="RPC timeout in seconds (overrides ECP_RPC_TIMEOUT)",
|
|
98
|
+
),
|
|
81
99
|
):
|
|
82
100
|
"""
|
|
83
101
|
Execute an evaluation run based on a manifest file.
|
|
@@ -92,7 +110,7 @@ def run(
|
|
|
92
110
|
config = ECPManifest.from_yaml(str(manifest))
|
|
93
111
|
|
|
94
112
|
# Run the Tests
|
|
95
|
-
runner = ECPRunner(config)
|
|
113
|
+
runner = ECPRunner(config, rpc_timeout=timeout)
|
|
96
114
|
result_summary = runner.run_scenarios()
|
|
97
115
|
total = int(result_summary.get("total", 0) or 0)
|
|
98
116
|
passed = int(result_summary.get("passed", 0) or 0)
|
|
@@ -122,6 +140,30 @@ def run(
|
|
|
122
140
|
if print_json:
|
|
123
141
|
typer.echo(json.dumps(report_payload, indent=2))
|
|
124
142
|
|
|
143
|
+
if export:
|
|
144
|
+
if export.lower() == "langsmith":
|
|
145
|
+
logger.info("Exporting results to LangSmith...")
|
|
146
|
+
try:
|
|
147
|
+
from langsmith import Client # type: ignore
|
|
148
|
+
client = Client()
|
|
149
|
+
project_name = config.name
|
|
150
|
+
for scenario in result_summary.get("scenarios", []):
|
|
151
|
+
for step in scenario.get("steps", []):
|
|
152
|
+
client.create_run(
|
|
153
|
+
name=scenario.get("name"),
|
|
154
|
+
run_type="chain",
|
|
155
|
+
inputs={"input": step.get("input")},
|
|
156
|
+
outputs={"output": step.get("output")},
|
|
157
|
+
project_name=project_name
|
|
158
|
+
)
|
|
159
|
+
logger.info("Successfully exported to LangSmith.")
|
|
160
|
+
except ImportError:
|
|
161
|
+
logger.warning("langsmith package not installed. Skipping export.")
|
|
162
|
+
except Exception as e:
|
|
163
|
+
logger.error("Failed to export to LangSmith: %s", e)
|
|
164
|
+
else:
|
|
165
|
+
logger.warning("Unsupported export target: %s", export)
|
|
166
|
+
|
|
125
167
|
if fail_on_error and failed > 0:
|
|
126
168
|
raise typer.Exit(code=2)
|
|
127
169
|
|
|
@@ -227,32 +269,96 @@ def conformance(
|
|
|
227
269
|
"--input",
|
|
228
270
|
help="Input text for the conformance step call",
|
|
229
271
|
),
|
|
272
|
+
json_out: Optional[Path] = typer.Option(
|
|
273
|
+
None,
|
|
274
|
+
"--json-out",
|
|
275
|
+
help="Path to save the machine-readable conformance report",
|
|
276
|
+
resolve_path=True,
|
|
277
|
+
),
|
|
278
|
+
print_json: bool = typer.Option(
|
|
279
|
+
False,
|
|
280
|
+
"--json",
|
|
281
|
+
help="Print only the machine-readable conformance report",
|
|
282
|
+
),
|
|
283
|
+
timeout: Optional[float] = typer.Option(
|
|
284
|
+
None,
|
|
285
|
+
"--timeout",
|
|
286
|
+
help="RPC timeout in seconds (overrides ECP_RPC_TIMEOUT)",
|
|
287
|
+
),
|
|
230
288
|
):
|
|
231
289
|
"""
|
|
232
|
-
|
|
290
|
+
Validate the core ECP protocol contract against an agent.
|
|
233
291
|
"""
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
292
|
+
rpc_timeout = resolve_rpc_timeout(timeout)
|
|
293
|
+
runner = ECPRunner(
|
|
294
|
+
type("Manifest", (), {"target": target, "scenarios": []})(),
|
|
295
|
+
rpc_timeout=rpc_timeout,
|
|
296
|
+
)
|
|
297
|
+
agent = runner._create_agent(target, rpc_timeout=rpc_timeout)
|
|
298
|
+
checks: List[Dict[str, Any]] = []
|
|
299
|
+
started = False
|
|
237
300
|
try:
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
301
|
+
agent.start()
|
|
302
|
+
started = True
|
|
303
|
+
initialize = _run_conformance_call(
|
|
304
|
+
agent,
|
|
305
|
+
"initialize response",
|
|
306
|
+
"agent/initialize",
|
|
307
|
+
{"config": {}},
|
|
308
|
+
result_validator=validate_initialize_result,
|
|
309
|
+
)
|
|
310
|
+
checks.append(initialize)
|
|
311
|
+
if initialize["passed"]:
|
|
312
|
+
checks.append(
|
|
313
|
+
_run_conformance_call(
|
|
314
|
+
agent,
|
|
315
|
+
"step result contract",
|
|
316
|
+
"agent/step",
|
|
317
|
+
{"input": step_input},
|
|
318
|
+
result_validator=validate_step_result,
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
else:
|
|
322
|
+
checks.append(_skipped_conformance_check("step result contract", "agent/step"))
|
|
249
323
|
except Exception as exc:
|
|
250
|
-
|
|
251
|
-
|
|
324
|
+
checks.append(
|
|
325
|
+
{
|
|
326
|
+
"name": "initialize response",
|
|
327
|
+
"method": "agent/initialize",
|
|
328
|
+
"passed": False,
|
|
329
|
+
"message": f"agent could not start: {exc}",
|
|
330
|
+
}
|
|
331
|
+
)
|
|
332
|
+
checks.append(_skipped_conformance_check("step result contract", "agent/step"))
|
|
252
333
|
finally:
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
334
|
+
if started:
|
|
335
|
+
checks.append(
|
|
336
|
+
_run_conformance_call(
|
|
337
|
+
agent,
|
|
338
|
+
"reset response",
|
|
339
|
+
"agent/reset",
|
|
340
|
+
{},
|
|
341
|
+
result_validator=validate_reset_result,
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
agent.stop()
|
|
345
|
+
else:
|
|
346
|
+
checks.append(_skipped_conformance_check("reset response", "agent/reset"))
|
|
347
|
+
|
|
348
|
+
report = build_conformance_report(target, checks)
|
|
349
|
+
rendered = json.dumps(report, indent=2)
|
|
350
|
+
if json_out:
|
|
351
|
+
json_out.write_text(rendered + "\n", encoding="utf-8")
|
|
352
|
+
if print_json:
|
|
353
|
+
typer.echo(rendered)
|
|
354
|
+
else:
|
|
355
|
+
for check in checks:
|
|
356
|
+
marker = "PASS" if check["passed"] else "FAIL"
|
|
357
|
+
typer.echo(f"{marker} | {check['name']} | {check['message']}")
|
|
358
|
+
typer.echo(f"Conformance: {report['passed']}/{report['total']} checks passed")
|
|
359
|
+
|
|
360
|
+
if not report["conformant"]:
|
|
361
|
+
raise typer.Exit(code=1)
|
|
256
362
|
|
|
257
363
|
|
|
258
364
|
@app.command()
|
|
@@ -316,15 +422,28 @@ def trend(
|
|
|
316
422
|
raise typer.Exit(code=2)
|
|
317
423
|
|
|
318
424
|
|
|
319
|
-
def
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
425
|
+
def _run_conformance_call(
|
|
426
|
+
agent: Any,
|
|
427
|
+
name: str,
|
|
428
|
+
method: str,
|
|
429
|
+
params: Dict[str, Any],
|
|
430
|
+
*,
|
|
431
|
+
result_validator: Optional[Callable[[Any], Any]] = None,
|
|
432
|
+
) -> Dict[str, Any]:
|
|
433
|
+
try:
|
|
434
|
+
response = agent.send_rpc(method, params)
|
|
435
|
+
except Exception as exc:
|
|
436
|
+
return {"name": name, "method": method, "passed": False, "message": str(exc)}
|
|
437
|
+
return conformance_check(name, method, response, result_validator=result_validator)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _skipped_conformance_check(name: str, method: str) -> Dict[str, Any]:
|
|
441
|
+
return {
|
|
442
|
+
"name": name,
|
|
443
|
+
"method": method,
|
|
444
|
+
"passed": False,
|
|
445
|
+
"message": "skipped because a prerequisite failed",
|
|
446
|
+
}
|
|
328
447
|
|
|
329
448
|
|
|
330
449
|
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")
|
|
@@ -2,13 +2,19 @@ import os
|
|
|
2
2
|
import re
|
|
3
3
|
from typing import Any, Dict, List, Tuple
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
try:
|
|
6
|
+
from .manifest import GraderConfig, StepConfig
|
|
7
|
+
except ImportError:
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
sys.path.append(os.path.dirname(__file__))
|
|
11
|
+
from manifest import GraderConfig, StepConfig # type: ignore
|
|
6
12
|
|
|
7
13
|
# Try importing OpenAI, but don't crash if it's missing (unless used)
|
|
8
14
|
try:
|
|
9
|
-
from openai import OpenAI
|
|
15
|
+
from openai import OpenAI # type: ignore
|
|
10
16
|
except ImportError:
|
|
11
|
-
OpenAI = None
|
|
17
|
+
OpenAI = None # type: ignore
|
|
12
18
|
|
|
13
19
|
|
|
14
20
|
def _llm_judge_model() -> str:
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StrictModel(BaseModel):
|
|
11
|
+
"""Base class that keeps runtime validation aligned with the JSON schema."""
|
|
12
|
+
|
|
13
|
+
model_config = ConfigDict(extra="forbid")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# --- The Grader (Assertion) Schema ---
|
|
17
|
+
class GraderConfig(StrictModel):
|
|
18
|
+
type: Literal["text_match", "llm_judge", "tool_usage"]
|
|
19
|
+
field: Literal["public_output", "evaluation_context", "private_thought"] = "public_output"
|
|
20
|
+
# For text_match
|
|
21
|
+
condition: Optional[Literal["contains", "equals", "does_not_contain", "regex"]] = None
|
|
22
|
+
value: Optional[str] = None
|
|
23
|
+
pattern: Optional[str] = None
|
|
24
|
+
# For llm_judge
|
|
25
|
+
prompt: Optional[str] = None
|
|
26
|
+
assertion: Optional[str] = None
|
|
27
|
+
# For tool_usage
|
|
28
|
+
tool_name: Optional[str] = None
|
|
29
|
+
arguments: Dict[str, Any] = Field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
@model_validator(mode="after")
|
|
32
|
+
def _validate_by_type(self) -> "GraderConfig":
|
|
33
|
+
if self.type == "text_match":
|
|
34
|
+
if not self.condition:
|
|
35
|
+
raise ValueError("text_match grader requires 'condition'")
|
|
36
|
+
if self.condition == "regex":
|
|
37
|
+
if not self.pattern:
|
|
38
|
+
raise ValueError("text_match with condition=regex requires 'pattern'")
|
|
39
|
+
elif self.value is None:
|
|
40
|
+
raise ValueError(f"text_match with condition={self.condition} requires 'value'")
|
|
41
|
+
|
|
42
|
+
elif self.type == "llm_judge":
|
|
43
|
+
if not self.prompt or not self.prompt.strip():
|
|
44
|
+
raise ValueError("llm_judge grader requires non-empty 'prompt'")
|
|
45
|
+
|
|
46
|
+
elif self.type == "tool_usage":
|
|
47
|
+
if not isinstance(self.arguments, dict):
|
|
48
|
+
raise ValueError("tool_usage grader 'arguments' must be an object/dictionary")
|
|
49
|
+
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
# --- The Step (Scenario) Schema ---
|
|
53
|
+
class StepConfig(StrictModel):
|
|
54
|
+
input: str
|
|
55
|
+
constraints: Dict[str, Any] = Field(default_factory=dict)
|
|
56
|
+
graders: List[GraderConfig] = Field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
class DatasetConfig(BaseModel):
|
|
59
|
+
type: Literal["csv", "jsonl"]
|
|
60
|
+
source: str
|
|
61
|
+
input_column: str = "input"
|
|
62
|
+
output_column: str = "output"
|
|
63
|
+
|
|
64
|
+
class ScenarioConfig(BaseModel):
|
|
65
|
+
name: str
|
|
66
|
+
steps: List[StepConfig] = Field(default_factory=list)
|
|
67
|
+
dataset: Optional[DatasetConfig] = None
|
|
68
|
+
|
|
69
|
+
@model_validator(mode="after")
|
|
70
|
+
def _validate_steps_or_dataset(self) -> "ScenarioConfig":
|
|
71
|
+
if not self.steps and not self.dataset:
|
|
72
|
+
raise ValueError("Scenario requires either 'steps' or 'dataset'")
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
# --- The Root Manifest Schema ---
|
|
76
|
+
class ECPManifest(StrictModel):
|
|
77
|
+
manifest_version: Literal["v1"]
|
|
78
|
+
name: str = Field(min_length=1)
|
|
79
|
+
target: str = Field(min_length=1)
|
|
80
|
+
scenarios: List[ScenarioConfig]
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_yaml(cls, path: str) -> "ECPManifest":
|
|
84
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
85
|
+
data = yaml.safe_load(f)
|
|
86
|
+
if not isinstance(data, dict):
|
|
87
|
+
raise ValueError("Manifest YAML root must be an object")
|
|
88
|
+
manifest = cls(**data)
|
|
89
|
+
base_dir = os.path.dirname(os.path.abspath(path))
|
|
90
|
+
manifest._resolve_datasets(base_dir)
|
|
91
|
+
return manifest
|
|
92
|
+
|
|
93
|
+
def _resolve_datasets(self, base_dir: str):
|
|
94
|
+
for scenario in self.scenarios:
|
|
95
|
+
if scenario.dataset and not scenario.steps:
|
|
96
|
+
source_path = os.path.join(base_dir, scenario.dataset.source)
|
|
97
|
+
if not os.path.exists(source_path):
|
|
98
|
+
raise ValueError(f"Dataset file not found: {source_path}")
|
|
99
|
+
|
|
100
|
+
if scenario.dataset.type == "csv":
|
|
101
|
+
with open(source_path, "r", encoding="utf-8") as f:
|
|
102
|
+
reader = csv.DictReader(f)
|
|
103
|
+
for row in reader:
|
|
104
|
+
input_text = row.get(scenario.dataset.input_column, "")
|
|
105
|
+
output_text = row.get(scenario.dataset.output_column, "")
|
|
106
|
+
scenario.steps.append(StepConfig(
|
|
107
|
+
input=input_text,
|
|
108
|
+
graders=[GraderConfig(
|
|
109
|
+
type="text_match",
|
|
110
|
+
condition="equals",
|
|
111
|
+
value=output_text
|
|
112
|
+
)] if output_text else []
|
|
113
|
+
))
|
|
114
|
+
elif scenario.dataset.type == "jsonl":
|
|
115
|
+
with open(source_path, "r", encoding="utf-8") as f:
|
|
116
|
+
for line in f:
|
|
117
|
+
if not line.strip():
|
|
118
|
+
continue
|
|
119
|
+
row = json.loads(line)
|
|
120
|
+
input_text = row.get(scenario.dataset.input_column, "")
|
|
121
|
+
output_text = row.get(scenario.dataset.output_column, "")
|
|
122
|
+
scenario.steps.append(StepConfig(
|
|
123
|
+
input=input_text,
|
|
124
|
+
graders=[GraderConfig(
|
|
125
|
+
type="text_match",
|
|
126
|
+
condition="equals",
|
|
127
|
+
value=output_text
|
|
128
|
+
)] if output_text else []
|
|
129
|
+
))
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Any, Dict
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from .runner import _create_agent
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def pytest_addoption(parser):
|
|
10
|
+
parser.addoption(
|
|
11
|
+
"--ecp-target",
|
|
12
|
+
action="store",
|
|
13
|
+
default=None,
|
|
14
|
+
help="The target command or HTTP URL for the ECP agent"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
class ECPAgentFixture:
|
|
18
|
+
def __init__(self, target: str, rpc_timeout: float = 30.0):
|
|
19
|
+
self.target = target
|
|
20
|
+
self.rpc_timeout = rpc_timeout
|
|
21
|
+
self._agent = None
|
|
22
|
+
|
|
23
|
+
def start(self):
|
|
24
|
+
self._agent = _create_agent(self.target, self.rpc_timeout)
|
|
25
|
+
self._agent.start()
|
|
26
|
+
resp = self._agent.send_rpc("agent/initialize", {"config": {}})
|
|
27
|
+
self._ensure_rpc_success(resp, "agent/initialize")
|
|
28
|
+
return self
|
|
29
|
+
|
|
30
|
+
def stop(self):
|
|
31
|
+
if self._agent:
|
|
32
|
+
self._agent.stop()
|
|
33
|
+
self._agent = None
|
|
34
|
+
|
|
35
|
+
def step(self, input_text: str) -> Dict[str, Any]:
|
|
36
|
+
resp = self._agent.send_rpc("agent/step", {"input": input_text})
|
|
37
|
+
self._ensure_rpc_success(resp, "agent/step")
|
|
38
|
+
return resp.get("result", {})
|
|
39
|
+
|
|
40
|
+
def reset(self) -> Dict[str, Any]:
|
|
41
|
+
resp = self._agent.send_rpc("agent/reset", {})
|
|
42
|
+
self._ensure_rpc_success(resp, "agent/reset")
|
|
43
|
+
return resp.get("result", {})
|
|
44
|
+
|
|
45
|
+
def _ensure_rpc_success(self, rpc_resp: Dict[str, Any], method: str) -> None:
|
|
46
|
+
if "error" not in rpc_resp:
|
|
47
|
+
return
|
|
48
|
+
error = rpc_resp.get("error") or {}
|
|
49
|
+
code = error.get("code")
|
|
50
|
+
message = error.get("message", "Unknown JSON-RPC error")
|
|
51
|
+
raise RuntimeError(
|
|
52
|
+
f"RPC call failed ({method}): code={code}, message={message}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@pytest.fixture
|
|
56
|
+
def ecp_agent(request):
|
|
57
|
+
target = request.config.getoption("--ecp-target")
|
|
58
|
+
marker = request.node.get_closest_marker("ecp")
|
|
59
|
+
if marker and "target" in marker.kwargs:
|
|
60
|
+
target = marker.kwargs["target"]
|
|
61
|
+
|
|
62
|
+
if not target:
|
|
63
|
+
pytest.skip("No ECP target specified. Use --ecp-target or @pytest.mark.ecp(target=...)")
|
|
64
|
+
|
|
65
|
+
rpc_timeout = float(os.environ.get("ECP_RPC_TIMEOUT", "30"))
|
|
66
|
+
agent = ECPAgentFixture(target, rpc_timeout=rpc_timeout)
|
|
67
|
+
agent.start()
|
|
68
|
+
|
|
69
|
+
yield agent
|
|
70
|
+
|
|
71
|
+
agent.stop()
|
|
72
|
+
|
|
73
|
+
def pytest_configure(config):
|
|
74
|
+
config.addinivalue_line(
|
|
75
|
+
"markers", "ecp: marks tests that require an ECP agent"
|
|
76
|
+
)
|