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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ecp-runtime
3
- Version: 0.3.2
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.3.2"
28
+ pip install "ecp-runtime==0.4.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.2"
10
+ pip install "ecp-runtime==0.4.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.2"
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" },
@@ -0,0 +1,2 @@
1
+ __version__ = "0.4.0"
2
+
@@ -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
- Run a small protocol conformance smoke test against an ECP agent.
260
+ Validate the core ECP protocol contract against an agent.
233
261
  """
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()
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
- 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")
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
- typer.echo(f"Conformance failed: {exc}", err=True)
251
- raise typer.Exit(code=1)
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
- agent.stop()
254
-
255
- typer.echo("Conformance smoke test passed")
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 _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")
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(BaseModel):
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(BaseModel):
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(BaseModel):
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(BaseModel):
55
- manifest_version: Literal["v1"] = "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": int(time.time() * 1000)
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
- return self._read_json_response()
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": int(time.time() * 1000),
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
- return self._parse_sse_response(raw)
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
- rpc_timeout = float(os.environ.get("ECP_RPC_TIMEOUT", "30"))
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 = agent.send_rpc("agent/initialize", {"config": {}})
243
- self._ensure_rpc_success(init_resp, scenario.name, step_idx=None, method="agent/initialize")
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 = agent.send_rpc("agent/step", {"input": step.input})
249
- self._ensure_rpc_success(rpc_resp, scenario.name, step_idx=i + 1, method="agent/step")
250
- result_data = rpc_resp.get("result", {})
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
- if "error" not in rpc_resp:
316
- return
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
- error = rpc_resp.get("error") or {}
319
- code = error.get("code")
320
- message = error.get("message", "Unknown JSON-RPC error")
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
- raise RuntimeError(
325
- f"RPC call failed ({method}) at {where}: code={code}, message={message}"
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 | None:
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
- x = list(range(len(pass_rates)))
105
- slope, _intercept = statistics.linear_regression(x, pass_rates)
106
- return slope
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 ECPRunner, HTTPAgentClient
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:
@@ -1,2 +0,0 @@
1
- __version__ = "0.3.2"
2
-
File without changes