ecp-runtime 0.3.0__tar.gz → 0.3.2__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.
@@ -208,3 +208,4 @@ cython_debug/
208
208
  marimo/_static/
209
209
  marimo/_lsp/
210
210
  __marimo__/
211
+ /worktrees
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ecp-runtime
3
- Version: 0.3.0
4
- Summary: The reference runtime for the Evaluation Context Protocol (ECP).
3
+ Version: 0.3.2
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/
7
7
  Project-URL: Repository, https://github.com/evaluation-context-protocol/ecp
8
8
  Project-URL: Issues, https://github.com/evaluation-context-protocol/ecp/issues
9
- Author-email: ECP Maintainers <hello@ecp.org>
9
+ Author-email: ECP Maintainers <aniket.wattamwar17@gmail.com>
10
10
  Requires-Python: >=3.9
11
11
  Requires-Dist: jinja2>=3.1.0
12
12
  Requires-Dist: openai>=1.0.0
@@ -18,33 +18,39 @@ Description-Content-Type: text/markdown
18
18
 
19
19
  # ECP Runtime
20
20
 
21
- This is the reference implementation of the Evaluation Context Protocol (ECP) Runtime.
22
- It includes the CLI tool `ecp` for running agent evaluations.
21
+ Reference runtime and CLI for the Evaluation Context Protocol (ECP).
22
+
23
+ ECP is a vendor-neutral protocol for testing agent outputs, tool calls, and evaluator-visible audit context across frameworks, models, eval platforms, and CI systems.
23
24
 
24
25
  ## Install
25
26
 
26
27
  ```bash
27
- pip install "ecp-runtime==0.2.9"
28
+ pip install "ecp-runtime==0.3.2"
28
29
  ```
29
30
 
30
- The latest stable PyPI release is now `0.2.9` and matches the current GitHub release line.
31
-
32
31
  ## Usage
33
32
 
34
- Run an evaluation manifest:
33
+ ```bash
34
+ ecp init
35
+ ecp validate ecp_eval/manifest.yaml
36
+ ecp run --manifest ecp_eval/manifest.yaml --json
37
+ ```
38
+
39
+ Run the flagship example:
35
40
 
36
41
  ```bash
37
- ecp run --manifest .\examples\langchain_demo\manifest.yaml
42
+ ecp run --manifest examples/customer_support_demo/manifest.yaml --report report.html
38
43
  ```
39
44
 
40
- You can also run via module entrypoint:
45
+ Useful commands:
41
46
 
42
47
  ```bash
43
- python -m ecp_runtime.cli run --manifest .\examples\langchain_demo\manifest.yaml
48
+ ecp validate examples/customer_support_demo/manifest.yaml
49
+ ecp conformance --target "python examples/customer_support_demo/agent.py"
50
+ ecp doctor
44
51
  ```
45
52
 
46
- Manifest `target` values may be either a command for the default stdio transport
47
- or an ECP Streamable HTTP endpoint:
53
+ Manifest `target` values may be either a command for the default stdio transport or an ECP Streamable HTTP endpoint:
48
54
 
49
55
  ```yaml
50
56
  target: "http://127.0.0.1:8765/ecp"
@@ -62,3 +68,4 @@ $env:ECP_LLM_JUDGE_MODEL="gpt-4o-mini"
62
68
  - Documentation: https://evaluationcontextprotocol.io/
63
69
  - Repository: https://github.com/evaluation-context-protocol/ecp
64
70
  - Issues: https://github.com/evaluation-context-protocol/ecp/issues
71
+
@@ -0,0 +1,53 @@
1
+ # ECP Runtime
2
+
3
+ Reference runtime and CLI for the Evaluation Context Protocol (ECP).
4
+
5
+ ECP is a vendor-neutral protocol for testing agent outputs, tool calls, and evaluator-visible audit context across frameworks, models, eval platforms, and CI systems.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install "ecp-runtime==0.3.2"
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ ecp init
17
+ ecp validate ecp_eval/manifest.yaml
18
+ ecp run --manifest ecp_eval/manifest.yaml --json
19
+ ```
20
+
21
+ Run the flagship example:
22
+
23
+ ```bash
24
+ ecp run --manifest examples/customer_support_demo/manifest.yaml --report report.html
25
+ ```
26
+
27
+ Useful commands:
28
+
29
+ ```bash
30
+ ecp validate examples/customer_support_demo/manifest.yaml
31
+ ecp conformance --target "python examples/customer_support_demo/agent.py"
32
+ ecp doctor
33
+ ```
34
+
35
+ Manifest `target` values may be either a command for the default stdio transport or an ECP Streamable HTTP endpoint:
36
+
37
+ ```yaml
38
+ target: "http://127.0.0.1:8765/ecp"
39
+ ```
40
+
41
+ If your manifest includes `llm_judge`, set an API key and optional judge model:
42
+
43
+ ```bash
44
+ $env:OPENAI_API_KEY="your_key_here"
45
+ $env:ECP_LLM_JUDGE_MODEL="gpt-4o-mini"
46
+ ```
47
+
48
+ ## Links
49
+
50
+ - Documentation: https://evaluationcontextprotocol.io/
51
+ - Repository: https://github.com/evaluation-context-protocol/ecp
52
+ - Issues: https://github.com/evaluation-context-protocol/ecp/issues
53
+
@@ -4,10 +4,10 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ecp-runtime"
7
- version = "0.3.0"
8
- description = "The reference runtime for the Evaluation Context Protocol (ECP)."
7
+ version = "0.3.2"
8
+ description = "Vendor-neutral runtime for portable AI agent evaluations with outputs, tool calls, and audit context."
9
9
  authors = [
10
- { name = "ECP Maintainers", email = "hello@ecp.org" },
10
+ { name = "ECP Maintainers", email = "aniket.wattamwar17@gmail.com" },
11
11
  ]
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.9"
@@ -29,3 +29,4 @@ Issues = "https://github.com/evaluation-context-protocol/ecp/issues"
29
29
  [project.scripts]
30
30
  ecp = "ecp_runtime.cli:app"
31
31
 
32
+
@@ -0,0 +1,2 @@
1
+ __version__ = "0.3.2"
2
+
@@ -0,0 +1,384 @@
1
+ import glob
2
+ import json
3
+ import logging
4
+ import os
5
+ import shutil
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ import typer
11
+ from pydantic import ValidationError
12
+
13
+ from .manifest import ECPManifest
14
+ from .reporter import HTMLReporter
15
+ from .runner import ECPRunner
16
+ from .trend import RunTrendAnalyzer
17
+
18
+ app = typer.Typer(
19
+ name="ecp",
20
+ help="Evaluation Context Protocol Runtime CLI",
21
+ add_completion=False
22
+ )
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def _configure_logging(verbose: bool) -> None:
28
+ level = logging.DEBUG if verbose else logging.INFO
29
+ logging.basicConfig(
30
+ level=level,
31
+ format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
32
+ datefmt="%H:%M:%S",
33
+ )
34
+
35
+
36
+ @app.callback()
37
+ def main():
38
+ """
39
+ Official Runtime for the Evaluation Context Protocol (ECP).
40
+ """
41
+ pass
42
+
43
+
44
+ @app.command()
45
+ def run(
46
+ manifest: Path = typer.Option(
47
+ ...,
48
+ "--manifest", "-m",
49
+ exists=True,
50
+ file_okay=True,
51
+ dir_okay=False,
52
+ writable=False,
53
+ readable=True,
54
+ resolve_path=True,
55
+ help="Path to the test manifest YAML file"
56
+ ),
57
+
58
+ verbose: bool = False,
59
+ report: Optional[Path] = typer.Option(
60
+ None,
61
+ "--report",
62
+ help="Path to save an HTML report (e.g., output.html)",
63
+ resolve_path=True,
64
+ ),
65
+ json_out: Optional[Path] = typer.Option(
66
+ None,
67
+ "--json-out",
68
+ help="Path to save a JSON report (e.g., output.json)",
69
+ resolve_path=True,
70
+ ),
71
+ print_json: bool = typer.Option(
72
+ False,
73
+ "--json",
74
+ help="Print the JSON report to stdout",
75
+ ),
76
+ fail_on_error: bool = typer.Option(
77
+ True,
78
+ "--fail-on-error/--no-fail-on-error",
79
+ help="Exit non-zero if any checks fail (useful for CI)",
80
+ ),
81
+ ):
82
+ """
83
+ Execute an evaluation run based on a manifest file.
84
+ """
85
+ _configure_logging(verbose)
86
+
87
+ logger.info("ECP Runtime Initializing...")
88
+ logger.info("Loading manifest: %s", manifest)
89
+
90
+ try:
91
+ # Load the YAML
92
+ config = ECPManifest.from_yaml(str(manifest))
93
+
94
+ # Run the Tests
95
+ runner = ECPRunner(config)
96
+ result_summary = runner.run_scenarios()
97
+ total = int(result_summary.get("total", 0) or 0)
98
+ passed = int(result_summary.get("passed", 0) or 0)
99
+ failed = max(total - passed, 0)
100
+
101
+ report_payload: Dict[str, Any] = {
102
+ "manifest": str(manifest),
103
+ "passed": passed,
104
+ "total": total,
105
+ "failed": failed,
106
+ "scenarios": result_summary.get("scenarios", []),
107
+ }
108
+
109
+ if report:
110
+ logger.info("Generating HTML report: %s", report)
111
+ reporter = HTMLReporter()
112
+ # Feed scenarios directly to reporter
113
+ for scenario in result_summary.get("scenarios", []):
114
+ reporter.add_scenario(scenario.get("name"), scenario.get("steps", []))
115
+ reporter.save(str(report))
116
+ logger.info("Report saved to %s", report)
117
+
118
+ if json_out:
119
+ json_out.write_text(json.dumps(report_payload, indent=2), encoding="utf-8")
120
+ logger.info("JSON report saved to %s", json_out)
121
+
122
+ if print_json:
123
+ typer.echo(json.dumps(report_payload, indent=2))
124
+
125
+ if fail_on_error and failed > 0:
126
+ raise typer.Exit(code=2)
127
+
128
+ except typer.Exit:
129
+ raise
130
+ except Exception as e:
131
+ logger.error("CRITICAL ERROR: %s", e)
132
+ if verbose:
133
+ raise e
134
+ sys.exit(1)
135
+
136
+
137
+ @app.command()
138
+ def validate(
139
+ manifest: Path = typer.Argument(
140
+ ...,
141
+ exists=True,
142
+ file_okay=True,
143
+ dir_okay=False,
144
+ readable=True,
145
+ resolve_path=True,
146
+ help="Path to the ECP manifest YAML file",
147
+ ),
148
+ ):
149
+ """
150
+ Validate a manifest without running the agent.
151
+ """
152
+ try:
153
+ config = ECPManifest.from_yaml(str(manifest))
154
+ except ValidationError as exc:
155
+ typer.echo(f"Manifest invalid: {manifest}", err=True)
156
+ typer.echo(str(exc), err=True)
157
+ raise typer.Exit(code=1)
158
+ except Exception as exc:
159
+ typer.echo(f"Manifest invalid: {manifest}", err=True)
160
+ typer.echo(str(exc), err=True)
161
+ raise typer.Exit(code=1)
162
+
163
+ step_count = sum(len(scenario.steps) for scenario in config.scenarios)
164
+ grader_count = sum(len(step.graders) for scenario in config.scenarios for step in scenario.steps)
165
+ typer.echo(f"Manifest valid: {manifest}")
166
+ typer.echo(f"Name: {config.name}")
167
+ typer.echo(f"Scenarios: {len(config.scenarios)}")
168
+ typer.echo(f"Steps: {step_count}")
169
+ typer.echo(f"Graders: {grader_count}")
170
+
171
+
172
+ @app.command()
173
+ def doctor():
174
+ """
175
+ Check the local ECP runtime environment.
176
+ """
177
+ typer.echo("ECP doctor")
178
+ typer.echo(f"Python: {sys.version.split()[0]}")
179
+ typer.echo(f"Executable: {sys.executable}")
180
+ typer.echo(f"Working directory: {Path.cwd()}")
181
+ typer.echo(f"OpenAI key: {'set' if os.environ.get('OPENAI_API_KEY') else 'not set'}")
182
+ typer.echo(f"Git: {'available' if shutil.which('git') else 'not found'}")
183
+
184
+
185
+ @app.command()
186
+ def init(
187
+ directory: Path = typer.Argument(
188
+ Path("ecp_eval"),
189
+ help="Directory where a starter ECP agent and manifest should be created",
190
+ ),
191
+ force: bool = typer.Option(
192
+ False,
193
+ "--force",
194
+ help="Overwrite existing starter files",
195
+ ),
196
+ ):
197
+ """
198
+ Create a minimal ECP evaluation project.
199
+ """
200
+ directory.mkdir(parents=True, exist_ok=True)
201
+ agent_path = directory / "agent.py"
202
+ manifest_path = directory / "manifest.yaml"
203
+
204
+ if not force and (agent_path.exists() or manifest_path.exists()):
205
+ typer.echo(f"Refusing to overwrite existing files in {directory}. Use --force to replace starters.", err=True)
206
+ raise typer.Exit(code=1)
207
+
208
+ agent_path.write_text(_starter_agent(), encoding="utf-8")
209
+ manifest_path.write_text(_starter_manifest(agent_path.as_posix()), encoding="utf-8")
210
+
211
+ typer.echo(f"Created {agent_path}")
212
+ typer.echo(f"Created {manifest_path}")
213
+ typer.echo(f"Try: ecp validate {manifest_path}")
214
+ typer.echo(f"Then: ecp run --manifest {manifest_path}")
215
+
216
+
217
+ @app.command()
218
+ def conformance(
219
+ target: str = typer.Option(
220
+ ...,
221
+ "--target",
222
+ "-t",
223
+ help="Agent command or ECP HTTP endpoint to check",
224
+ ),
225
+ step_input: str = typer.Option(
226
+ "hello",
227
+ "--input",
228
+ help="Input text for the conformance step call",
229
+ ),
230
+ ):
231
+ """
232
+ Run a small protocol conformance smoke test against an ECP agent.
233
+ """
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()
237
+ 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")
249
+ except Exception as exc:
250
+ typer.echo(f"Conformance failed: {exc}", err=True)
251
+ raise typer.Exit(code=1)
252
+ finally:
253
+ agent.stop()
254
+
255
+ typer.echo("Conformance smoke test passed")
256
+
257
+
258
+ @app.command()
259
+ def trend(
260
+ pattern: str = typer.Argument(
261
+ ...,
262
+ help="Glob pattern matching saved JSON report files (e.g. 'results/run-*.json')",
263
+ ),
264
+ window: int = typer.Option(
265
+ 20,
266
+ "--window",
267
+ "-w",
268
+ help="Maximum number of recent runs to include in the analysis",
269
+ min=1,
270
+ ),
271
+ exit_on_regression: bool = typer.Option(
272
+ False,
273
+ "--exit-on-regression",
274
+ help="Exit with code 2 when a degrading trend is detected (for CI gates)",
275
+ ),
276
+ verbose: bool = typer.Option(
277
+ False,
278
+ "--verbose",
279
+ "-v",
280
+ help="Enable verbose output",
281
+ ),
282
+ ):
283
+ """
284
+ Analyse pass-rate trends across a sequence of saved JSON report files.
285
+ """
286
+ _configure_logging(verbose)
287
+
288
+ matched: List[Path] = sorted(Path(path) for path in glob.glob(pattern, recursive=True))
289
+
290
+ if not matched:
291
+ typer.echo(f"No files matched the pattern: {pattern}", err=True)
292
+ raise typer.Exit(code=1)
293
+
294
+ typer.echo(f"Found {len(matched)} report(s). Analysing last {window}...")
295
+
296
+ analyzer = RunTrendAnalyzer(matched, window=window)
297
+ report = analyzer.analyze()
298
+
299
+ typer.echo("")
300
+ typer.echo(f"{'Run':<5} {'Manifest':<45} {'Passed':>6} {'Total':>6} {'Rate':>6}")
301
+ typer.echo("-" * 74)
302
+ for index, run in enumerate(report.runs, start=1):
303
+ manifest_label = Path(run.manifest).name if len(run.manifest) > 45 else run.manifest
304
+ rate_pct = f"{run.pass_rate * 100:.1f}%"
305
+ typer.echo(f"{index:<5} {manifest_label:<45} {run.passed:>6} {run.total:>6} {rate_pct:>6}")
306
+
307
+ typer.echo("")
308
+ typer.echo(f"Trend Direction : {report.direction.upper()}")
309
+ typer.echo(f"Slope (per run) : {report.pass_rate_slope:+.6f}")
310
+ typer.echo(f"Window : {len(report.runs)} run(s) analysed (max {report.window})")
311
+ typer.echo(f"Regression flag : {'YES' if report.any_regression else 'No'}")
312
+ typer.echo("")
313
+
314
+ if report.any_regression and exit_on_regression:
315
+ typer.echo("Regression detected. Exiting with code 2 (--exit-on-regression).", err=True)
316
+ raise typer.Exit(code=2)
317
+
318
+
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")
328
+
329
+
330
+ def _starter_agent() -> str:
331
+ return '''from ecp import Result, agent, on_reset, on_step, serve
332
+
333
+
334
+ @agent(name="StarterAgent")
335
+ class StarterAgent:
336
+ def __init__(self):
337
+ self.seen = []
338
+
339
+ @on_step
340
+ def step(self, user_input: str):
341
+ self.seen.append(user_input)
342
+ return Result(
343
+ public_output=f"Echo: {user_input}",
344
+ evaluation_context="The starter agent echoed the user input.",
345
+ tool_calls=[{"name": "echo", "arguments": {"text": user_input}}],
346
+ )
347
+
348
+ @on_reset
349
+ def reset(self):
350
+ self.seen.clear()
351
+
352
+
353
+ if __name__ == "__main__":
354
+ serve(StarterAgent())
355
+ '''
356
+
357
+
358
+ def _starter_manifest(agent_filename: str) -> str:
359
+ return f'''manifest_version: "v1"
360
+ name: "Starter ECP Evaluation"
361
+ target: 'python {agent_filename}'
362
+
363
+ scenarios:
364
+ - name: "Echo contract"
365
+ steps:
366
+ - input: "hello ecp"
367
+ graders:
368
+ - type: text_match
369
+ field: public_output
370
+ condition: contains
371
+ value: "hello ecp"
372
+ - type: text_match
373
+ field: evaluation_context
374
+ condition: contains
375
+ value: "echoed"
376
+ - type: tool_usage
377
+ tool_name: "echo"
378
+ arguments:
379
+ text: "hello ecp"
380
+ '''
381
+
382
+
383
+ if __name__ == "__main__":
384
+ app()
@@ -112,8 +112,10 @@ def evaluate_step(step_config: StepConfig, result_obj: Any) -> List[Dict[str, An
112
112
  target_text = ""
113
113
  if grader.field == "public_output":
114
114
  target_text = result_obj.public_output
115
+ elif grader.field == "evaluation_context":
116
+ target_text = getattr(result_obj, "evaluation_context", None)
115
117
  elif grader.field == "private_thought":
116
- target_text = result_obj.private_thought
118
+ target_text = getattr(result_obj, "private_thought", None) or getattr(result_obj, "evaluation_context", None)
117
119
 
118
120
  passed = False
119
121
  reasoning = ""
@@ -132,7 +134,7 @@ def evaluate_step(step_config: StepConfig, result_obj: Any) -> List[Dict[str, An
132
134
 
133
135
  check_results.append({
134
136
  "type": grader.type,
135
- "field": grader.field,
137
+ "field": "tool_calls" if grader.type == "tool_usage" else grader.field,
136
138
  "passed": passed,
137
139
  "score": score,
138
140
  "reasoning": reasoning
@@ -7,7 +7,7 @@ from pydantic import BaseModel, Field, model_validator
7
7
  # --- The Grader (Assertion) Schema ---
8
8
  class GraderConfig(BaseModel):
9
9
  type: Literal["text_match", "llm_judge", "tool_usage"]
10
- field: Literal["public_output", "private_thought"] = "public_output"
10
+ field: Literal["public_output", "evaluation_context", "private_thought"] = "public_output"
11
11
  # For text_match
12
12
  condition: Optional[Literal["contains", "equals", "does_not_contain", "regex"]] = None
13
13
  value: Optional[str] = None
@@ -30,6 +30,13 @@ HTML_TEMPLATE = """
30
30
  <div style="border-top: 1px solid #eee; margin-top: 10px; padding-top: 10px;">
31
31
  <p><strong>Input:</strong> {{ step.input }}</p>
32
32
  <p><strong>Output:</strong> {{ step.output }}</p>
33
+ {% if step.evaluation_context %}
34
+ <p><strong>Evaluation context:</strong> {{ step.evaluation_context }}</p>
35
+ {% endif %}
36
+ {% if step.tool_calls %}
37
+ <h4>Tool Calls:</h4>
38
+ <pre>{{ step.tool_calls | tojson(indent=2) }}</pre>
39
+ {% endif %}
33
40
 
34
41
  <h4>Graders:</h4>
35
42
  <ul>
@@ -26,6 +26,7 @@ logger = logging.getLogger(__name__)
26
26
  class StepResult:
27
27
  status: str
28
28
  public_output: Optional[str] = None
29
+ evaluation_context: Optional[str] = None
29
30
  private_thought: Optional[str] = None
30
31
  logs: Optional[str] = None
31
32
  tool_calls: Optional[List[Dict[str, Any]]] = None
@@ -252,14 +253,15 @@ class ECPRunner:
252
253
  step_result = StepResult(
253
254
  status=result_data.get("status", "done"),
254
255
  public_output=result_data.get("public_output"),
255
- private_thought=result_data.get("private_thought"),
256
+ evaluation_context=result_data.get("evaluation_context") or result_data.get("private_thought"),
257
+ private_thought=result_data.get("private_thought") or result_data.get("evaluation_context"),
256
258
  tool_calls=result_data.get("tool_calls") if isinstance(result_data.get("tool_calls"), list) else None
257
259
  )
258
260
 
259
261
  logger.info("Step %d: Input='%s'", i + 1, step.input)
260
262
  logger.info("Output: %s", step_result.public_output)
261
- if step_result.private_thought:
262
- logger.debug("Thought: %s", step_result.private_thought)
263
+ if step_result.evaluation_context:
264
+ logger.debug("Evaluation context: %s", step_result.evaluation_context)
263
265
 
264
266
  checks = evaluate_step(step, step_result)
265
267
 
@@ -278,6 +280,8 @@ class ECPRunner:
278
280
  scenario_steps.append({
279
281
  "input": step.input,
280
282
  "output": step_result.public_output,
283
+ "evaluation_context": step_result.evaluation_context,
284
+ "tool_calls": step_result.tool_calls or [],
281
285
  "checks": checks
282
286
  })
283
287
 
@@ -72,6 +72,29 @@ class CLISmokeTests(unittest.TestCase):
72
72
  result = self.runner.invoke(app, ["run", "--manifest", self.manifest_path, "--no-fail-on-error"])
73
73
  self.assertEqual(result.exit_code, 0, msg=result.output)
74
74
 
75
+ def test_validate_command(self) -> None:
76
+ result = self.runner.invoke(app, ["validate", self.manifest_path])
77
+
78
+ self.assertEqual(result.exit_code, 0, msg=result.output)
79
+ self.assertIn("Manifest valid", result.output)
80
+
81
+ def test_doctor_command(self) -> None:
82
+ result = self.runner.invoke(app, ["doctor"])
83
+
84
+ self.assertEqual(result.exit_code, 0, msg=result.output)
85
+ self.assertIn("ECP doctor", result.output)
86
+
87
+ def test_init_command_creates_starter_files(self) -> None:
88
+ with tempfile.TemporaryDirectory() as tmpdir:
89
+ target = Path(tmpdir) / "evals"
90
+ result = self.runner.invoke(app, ["init", str(target)])
91
+
92
+ self.assertEqual(result.exit_code, 0, msg=result.output)
93
+ self.assertTrue((target / "agent.py").exists())
94
+ manifest = target / "manifest.yaml"
95
+ self.assertTrue(manifest.exists())
96
+ self.assertIn("evaluation_context", manifest.read_text(encoding="utf-8"))
97
+
75
98
 
76
99
  if __name__ == "__main__":
77
100
  unittest.main()
@@ -81,6 +81,26 @@ class EvaluateStepSmokeTests(unittest.TestCase):
81
81
  self.assertEqual(len(checks), 1)
82
82
  self.assertTrue(checks[0]["passed"])
83
83
 
84
+ def test_step_can_grade_evaluation_context(self) -> None:
85
+ grader = GraderConfig(
86
+ type="text_match",
87
+ field="evaluation_context",
88
+ condition="contains",
89
+ value="policy checked",
90
+ )
91
+ step = StepConfig(input="refund", graders=[grader])
92
+ result_obj = SimpleNamespace(
93
+ public_output="ok",
94
+ evaluation_context="policy checked before response",
95
+ private_thought=None,
96
+ tool_calls=[],
97
+ )
98
+
99
+ from ecp_runtime.graders import evaluate_step
100
+
101
+ checks = evaluate_step(step, result_obj)
102
+ self.assertTrue(checks[0]["passed"])
103
+
84
104
 
85
105
  if __name__ == "__main__":
86
106
  unittest.main()
@@ -33,6 +33,16 @@ class ManifestValidationTests(unittest.TestCase):
33
33
  with self.assertRaises(ValidationError):
34
34
  GraderConfig(type="unknown") # type: ignore[arg-type]
35
35
 
36
+ def test_evaluation_context_field_allowed(self) -> None:
37
+ grader = GraderConfig(
38
+ type="text_match",
39
+ field="evaluation_context",
40
+ condition="contains",
41
+ value="checked",
42
+ )
43
+
44
+ self.assertEqual(grader.field, "evaluation_context")
45
+
36
46
  def test_manifest_root_must_be_mapping(self) -> None:
37
47
  with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False, encoding="utf-8") as tmp:
38
48
  tmp.write("- not: a mapping\n")
@@ -33,7 +33,11 @@ class RunnerTests(unittest.TestCase):
33
33
  def send_rpc(self, method, params=None):
34
34
  if method == "agent/initialize":
35
35
  return {"jsonrpc": "2.0", "id": 1, "result": {"name": "x", "capabilities": {}}}
36
- return {"jsonrpc": "2.0", "id": 2, "result": {"status": "done", "public_output": "ok"}}
36
+ return {
37
+ "jsonrpc": "2.0",
38
+ "id": 2,
39
+ "result": {"status": "done", "public_output": "ok", "evaluation_context": "checked"},
40
+ }
37
41
 
38
42
  with mock.patch("ecp_runtime.runner.AgentProcess", FakeAgentProcess):
39
43
  output = ECPRunner(self._manifest()).run_scenarios()
@@ -41,6 +45,7 @@ class RunnerTests(unittest.TestCase):
41
45
  self.assertEqual(output["total"], 0)
42
46
  self.assertEqual(len(output["scenarios"]), 1)
43
47
  self.assertEqual(output["scenarios"][0]["name"], "Scenario A")
48
+ self.assertEqual(output["scenarios"][0]["steps"][0]["evaluation_context"], "checked")
44
49
 
45
50
  def test_runner_raises_on_rpc_error_with_context(self) -> None:
46
51
  class FakeAgentProcess:
@@ -1,46 +0,0 @@
1
- # ECP Runtime
2
-
3
- This is the reference implementation of the Evaluation Context Protocol (ECP) Runtime.
4
- It includes the CLI tool `ecp` for running agent evaluations.
5
-
6
- ## Install
7
-
8
- ```bash
9
- pip install "ecp-runtime==0.2.9"
10
- ```
11
-
12
- The latest stable PyPI release is now `0.2.9` and matches the current GitHub release line.
13
-
14
- ## Usage
15
-
16
- Run an evaluation manifest:
17
-
18
- ```bash
19
- ecp run --manifest .\examples\langchain_demo\manifest.yaml
20
- ```
21
-
22
- You can also run via module entrypoint:
23
-
24
- ```bash
25
- python -m ecp_runtime.cli run --manifest .\examples\langchain_demo\manifest.yaml
26
- ```
27
-
28
- Manifest `target` values may be either a command for the default stdio transport
29
- or an ECP Streamable HTTP endpoint:
30
-
31
- ```yaml
32
- target: "http://127.0.0.1:8765/ecp"
33
- ```
34
-
35
- If your manifest includes `llm_judge`, set an API key and optional judge model:
36
-
37
- ```bash
38
- $env:OPENAI_API_KEY="your_key_here"
39
- $env:ECP_LLM_JUDGE_MODEL="gpt-4o-mini"
40
- ```
41
-
42
- ## Links
43
-
44
- - Documentation: https://evaluationcontextprotocol.io/
45
- - Repository: https://github.com/evaluation-context-protocol/ecp
46
- - Issues: https://github.com/evaluation-context-protocol/ecp/issues
@@ -1 +0,0 @@
1
- __version__ = "0.2.9"
@@ -1,205 +0,0 @@
1
- import glob
2
- import json
3
- import logging
4
- import os
5
- import sys
6
- from pathlib import Path
7
- from typing import Any, Dict, List, Optional
8
-
9
- import typer
10
-
11
- from .reporter import HTMLReporter
12
- from .trend import RunTrendAnalyzer
13
-
14
- # Import local modules (Using relative imports)
15
- try:
16
- from .manifest import ECPManifest
17
- from .runner import ECPRunner
18
- except ImportError:
19
- # Fallback for direct execution debugging
20
- sys.path.append(os.path.dirname(__file__))
21
- from manifest import ECPManifest
22
- from runner import ECPRunner
23
-
24
- app = typer.Typer(
25
- name="ecp",
26
- help="Evaluation Context Protocol Runtime CLI",
27
- add_completion=False
28
- )
29
-
30
- logger = logging.getLogger(__name__)
31
-
32
-
33
- def _configure_logging(verbose: bool) -> None:
34
- level = logging.DEBUG if verbose else logging.INFO
35
- logging.basicConfig(
36
- level=level,
37
- format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
38
- datefmt="%H:%M:%S",
39
- )
40
-
41
-
42
- @app.callback()
43
- def main():
44
- """
45
- Official Runtime for the Evaluation Context Protocol (ECP).
46
- """
47
- pass
48
-
49
-
50
- @app.command()
51
- def run(
52
- manifest: Path = typer.Option(
53
- ...,
54
- "--manifest", "-m",
55
- exists=True,
56
- file_okay=True,
57
- dir_okay=False,
58
- writable=False,
59
- readable=True,
60
- resolve_path=True,
61
- help="Path to the test manifest YAML file"
62
- ),
63
-
64
- verbose: bool = False,
65
- report: Optional[Path] = typer.Option(
66
- None,
67
- "--report",
68
- help="Path to save an HTML report (e.g., output.html)",
69
- resolve_path=True,
70
- ),
71
- json_out: Optional[Path] = typer.Option(
72
- None,
73
- "--json-out",
74
- help="Path to save a JSON report (e.g., output.json)",
75
- resolve_path=True,
76
- ),
77
- print_json: bool = typer.Option(
78
- False,
79
- "--json",
80
- help="Print the JSON report to stdout",
81
- ),
82
- fail_on_error: bool = typer.Option(
83
- True,
84
- "--fail-on-error/--no-fail-on-error",
85
- help="Exit non-zero if any checks fail (useful for CI)",
86
- ),
87
- ):
88
- """
89
- Execute an evaluation run based on a manifest file.
90
- """
91
- _configure_logging(verbose)
92
-
93
- logger.info("ECP Runtime Initializing...")
94
- logger.info("Loading manifest: %s", manifest)
95
-
96
- try:
97
- # Load the YAML
98
- config = ECPManifest.from_yaml(str(manifest))
99
-
100
- # Run the Tests
101
- runner = ECPRunner(config)
102
- result_summary = runner.run_scenarios()
103
- total = int(result_summary.get("total", 0) or 0)
104
- passed = int(result_summary.get("passed", 0) or 0)
105
- failed = max(total - passed, 0)
106
-
107
- report_payload: Dict[str, Any] = {
108
- "manifest": str(manifest),
109
- "passed": passed,
110
- "total": total,
111
- "failed": failed,
112
- "scenarios": result_summary.get("scenarios", []),
113
- }
114
-
115
- if report:
116
- logger.info("Generating HTML report: %s", report)
117
- reporter = HTMLReporter()
118
- # Feed scenarios directly to reporter
119
- for scenario in result_summary.get("scenarios", []):
120
- reporter.add_scenario(scenario.get("name"), scenario.get("steps", []))
121
- reporter.save(str(report))
122
- logger.info("Report saved to %s", report)
123
-
124
- if json_out:
125
- json_out.write_text(json.dumps(report_payload, indent=2), encoding="utf-8")
126
- logger.info("JSON report saved to %s", json_out)
127
-
128
- if print_json:
129
- typer.echo(json.dumps(report_payload, indent=2))
130
-
131
- if fail_on_error and failed > 0:
132
- raise typer.Exit(code=2)
133
-
134
- except typer.Exit:
135
- raise
136
- except Exception as e:
137
- logger.error("CRITICAL ERROR: %s", e)
138
- if verbose:
139
- raise e
140
- sys.exit(1)
141
-
142
-
143
- @app.command()
144
- def trend(
145
- pattern: str = typer.Argument(
146
- ...,
147
- help="Glob pattern matching saved JSON report files (e.g. 'results/run-*.json')",
148
- ),
149
- window: int = typer.Option(
150
- 20,
151
- "--window",
152
- "-w",
153
- help="Maximum number of recent runs to include in the analysis",
154
- min=1,
155
- ),
156
- exit_on_regression: bool = typer.Option(
157
- False,
158
- "--exit-on-regression",
159
- help="Exit with code 2 when a degrading trend is detected (for CI gates)",
160
- ),
161
- verbose: bool = typer.Option(
162
- False,
163
- "--verbose",
164
- "-v",
165
- help="Enable verbose output",
166
- ),
167
- ):
168
- """
169
- Analyse pass-rate trends across a sequence of saved JSON report files.
170
- """
171
- _configure_logging(verbose)
172
-
173
- matched: List[Path] = sorted(Path(path) for path in glob.glob(pattern, recursive=True))
174
-
175
- if not matched:
176
- typer.echo(f"No files matched the pattern: {pattern}", err=True)
177
- raise typer.Exit(code=1)
178
-
179
- typer.echo(f"Found {len(matched)} report(s). Analysing last {window}...")
180
-
181
- analyzer = RunTrendAnalyzer(matched, window=window)
182
- report = analyzer.analyze()
183
-
184
- typer.echo("")
185
- typer.echo(f"{'Run':<5} {'Manifest':<45} {'Passed':>6} {'Total':>6} {'Rate':>6}")
186
- typer.echo("-" * 74)
187
- for index, run in enumerate(report.runs, start=1):
188
- manifest_label = Path(run.manifest).name if len(run.manifest) > 45 else run.manifest
189
- rate_pct = f"{run.pass_rate * 100:.1f}%"
190
- typer.echo(f"{index:<5} {manifest_label:<45} {run.passed:>6} {run.total:>6} {rate_pct:>6}")
191
-
192
- typer.echo("")
193
- typer.echo(f"Trend Direction : {report.direction.upper()}")
194
- typer.echo(f"Slope (per run) : {report.pass_rate_slope:+.6f}")
195
- typer.echo(f"Window : {len(report.runs)} run(s) analysed (max {report.window})")
196
- typer.echo(f"Regression flag : {'YES' if report.any_regression else 'No'}")
197
- typer.echo("")
198
-
199
- if report.any_regression and exit_on_regression:
200
- typer.echo("Regression detected. Exiting with code 2 (--exit-on-regression).", err=True)
201
- raise typer.Exit(code=2)
202
-
203
-
204
- if __name__ == "__main__":
205
- app()