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.
Files changed (23) hide show
  1. {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/.gitignore +4 -0
  2. {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/PKG-INFO +2 -2
  3. {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/README.md +1 -1
  4. {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/pyproject.toml +4 -1
  5. ecp_runtime-0.5.0/src/ecp_runtime/__init__.py +2 -0
  6. {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/src/ecp_runtime/cli.py +152 -33
  7. ecp_runtime-0.5.0/src/ecp_runtime/conformance.py +108 -0
  8. {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/src/ecp_runtime/graders.py +9 -3
  9. ecp_runtime-0.5.0/src/ecp_runtime/manifest.py +129 -0
  10. ecp_runtime-0.5.0/src/ecp_runtime/pytest_plugin.py +76 -0
  11. {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/src/ecp_runtime/runner.py +94 -22
  12. {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/src/ecp_runtime/trend.py +13 -6
  13. {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/tests/test_cli.py +76 -0
  14. ecp_runtime-0.5.0/tests/test_conformance.py +91 -0
  15. {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/tests/test_example_integrations.py +56 -0
  16. {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/tests/test_manifest.py +22 -0
  17. {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/tests/test_runner.py +96 -1
  18. {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/tests/test_trend.py +3 -0
  19. ecp_runtime-0.3.3/src/ecp_runtime/__init__.py +0 -2
  20. ecp_runtime-0.3.3/src/ecp_runtime/manifest.py +0 -66
  21. {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/src/ecp_runtime/reporter.py +0 -0
  22. {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/tests/__init__.py +0 -0
  23. {ecp_runtime-0.3.3 → ecp_runtime-0.5.0}/tests/test_graders.py +0 -0
@@ -208,4 +208,8 @@ cython_debug/
208
208
  marimo/_static/
209
209
  marimo/_lsp/
210
210
  __marimo__/
211
+ *.zip
212
+ /ECPBrain
213
+ /worktrees
214
+ /logs
211
215
  /worktrees
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ecp-runtime
3
- Version: 0.3.3
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.3.3"
28
+ pip install "ecp-runtime==0.5.0"
29
29
  ```
30
30
 
31
31
  ## Usage
@@ -7,7 +7,7 @@ ECP is a vendor-neutral protocol for testing agent outputs, tool calls, and eval
7
7
  ## Install
8
8
 
9
9
  ```bash
10
- pip install "ecp-runtime==0.3.3"
10
+ pip install "ecp-runtime==0.5.0"
11
11
  ```
12
12
 
13
13
  ## Usage
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ecp-runtime"
7
- version = "0.3.3"
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
 
@@ -0,0 +1,2 @@
1
+ __version__ = "0.5.0"
2
+
@@ -1,18 +1,26 @@
1
- import glob
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
- Run a small protocol conformance smoke test against an ECP agent.
290
+ Validate the core ECP protocol contract against an agent.
233
291
  """
234
- runner = ECPRunner(type("Manifest", (), {"target": target, "scenarios": []})())
235
- agent = runner._create_agent(target, rpc_timeout=float(os.environ.get("ECP_RPC_TIMEOUT", "30")))
236
- agent.start()
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
- init_resp = agent.send_rpc("agent/initialize", {"config": {}})
239
- _assert_rpc_result(init_resp, "agent/initialize")
240
- step_resp = agent.send_rpc("agent/step", {"input": step_input})
241
- _assert_rpc_result(step_resp, "agent/step")
242
- result = step_resp.get("result", {})
243
- if not isinstance(result, dict):
244
- raise ValueError("agent/step result must be an object")
245
- if "public_output" not in result and "evaluation_context" not in result and "tool_calls" not in result:
246
- raise ValueError("agent/step result should expose public_output, evaluation_context, or tool_calls")
247
- reset_resp = agent.send_rpc("agent/reset", {})
248
- _assert_rpc_result(reset_resp, "agent/reset")
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
- typer.echo(f"Conformance failed: {exc}", err=True)
251
- raise typer.Exit(code=1)
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
- agent.stop()
254
-
255
- typer.echo("Conformance smoke test passed")
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 _assert_rpc_result(response: Dict[str, Any], method: str) -> None:
320
- if not isinstance(response, dict):
321
- raise ValueError(f"{method} response must be a JSON-RPC object")
322
- if response.get("jsonrpc") != "2.0":
323
- raise ValueError(f"{method} response must include jsonrpc='2.0'")
324
- if "error" in response:
325
- raise ValueError(f"{method} returned error: {response['error']}")
326
- if "result" not in response:
327
- raise ValueError(f"{method} response must include result")
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
- from .manifest import GraderConfig, StepConfig
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
+ )