ecp-runtime 0.2.9__tar.gz → 0.3.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.
@@ -31,7 +31,7 @@ MANIFEST
31
31
  # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
32
  *.manifest
33
33
  *.spec
34
-
34
+ /specs/
35
35
  # Installer logs
36
36
  pip-log.txt
37
37
  pip-delete-this-directory.txt
@@ -50,7 +50,10 @@ coverage.xml
50
50
  .hypothesis/
51
51
  .pytest_cache/
52
52
  cover/
53
-
53
+ node_modules/
54
+ .github/agents
55
+ .github/prompts/
56
+ .specify/
54
57
  # Translations
55
58
  *.mo
56
59
  *.pot
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ecp-runtime
3
- Version: 0.2.9
3
+ Version: 0.3.0
4
4
  Summary: The reference runtime for the Evaluation Context Protocol (ECP).
5
5
  Project-URL: Homepage, https://github.com/evaluation-context-protocol/ecp
6
6
  Project-URL: Documentation, https://evaluationcontextprotocol.io/
@@ -43,6 +43,13 @@ You can also run via module entrypoint:
43
43
  python -m ecp_runtime.cli run --manifest .\examples\langchain_demo\manifest.yaml
44
44
  ```
45
45
 
46
+ Manifest `target` values may be either a command for the default stdio transport
47
+ or an ECP Streamable HTTP endpoint:
48
+
49
+ ```yaml
50
+ target: "http://127.0.0.1:8765/ecp"
51
+ ```
52
+
46
53
  If your manifest includes `llm_judge`, set an API key and optional judge model:
47
54
 
48
55
  ```bash
@@ -25,6 +25,13 @@ You can also run via module entrypoint:
25
25
  python -m ecp_runtime.cli run --manifest .\examples\langchain_demo\manifest.yaml
26
26
  ```
27
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
+
28
35
  If your manifest includes `llm_judge`, set an API key and optional judge model:
29
36
 
30
37
  ```bash
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ecp-runtime"
7
- version = "0.2.9"
7
+ version = "0.3.0"
8
8
  description = "The reference runtime for the Evaluation Context Protocol (ECP)."
9
9
  authors = [
10
10
  { name = "ECP Maintainers", email = "hello@ecp.org" },
@@ -14,6 +14,8 @@ import threading
14
14
  import time
15
15
  from dataclasses import dataclass
16
16
  from typing import Any, Dict, List, Optional
17
+ from urllib import error, request
18
+ from urllib.parse import urlparse
17
19
 
18
20
  from .graders import evaluate_step
19
21
 
@@ -148,6 +150,75 @@ class AgentProcess:
148
150
  return ""
149
151
 
150
152
 
153
+ class HTTPAgentClient:
154
+ """JSON-RPC client for ECP Streamable HTTP endpoints."""
155
+
156
+ def __init__(self, endpoint: str, rpc_timeout: float = 30.0):
157
+ self.endpoint = endpoint
158
+ self.rpc_timeout = rpc_timeout
159
+
160
+ def start(self):
161
+ return None
162
+
163
+ def stop(self):
164
+ return None
165
+
166
+ def send_rpc(self, method: str, params: Dict[str, Any] = None) -> Dict[str, Any]:
167
+ if not params:
168
+ params = {}
169
+
170
+ payload = {
171
+ "jsonrpc": "2.0",
172
+ "method": method,
173
+ "params": params,
174
+ "id": int(time.time() * 1000),
175
+ }
176
+ body = json.dumps(payload).encode("utf-8")
177
+ req = request.Request(
178
+ self.endpoint,
179
+ data=body,
180
+ method="POST",
181
+ headers={
182
+ "Accept": "application/json, text/event-stream",
183
+ "Content-Type": "application/json",
184
+ },
185
+ )
186
+
187
+ try:
188
+ with request.urlopen(req, timeout=self.rpc_timeout) as resp:
189
+ content_type = resp.headers.get("Content-Type", "")
190
+ raw = resp.read().decode("utf-8")
191
+ except error.HTTPError as exc:
192
+ raw = exc.read().decode("utf-8", errors="replace")
193
+ raise RuntimeError(
194
+ f"HTTP RPC failed: status={exc.code}, body={raw or exc.reason}"
195
+ ) from exc
196
+ except error.URLError as exc:
197
+ raise RuntimeError(f"HTTP RPC failed: {exc.reason}") from exc
198
+
199
+ if "text/event-stream" in content_type:
200
+ return self._parse_sse_response(raw)
201
+ if not raw:
202
+ raise RuntimeError("HTTP RPC response was empty")
203
+ payload = json.loads(raw)
204
+ if not isinstance(payload, dict):
205
+ raise RuntimeError("HTTP RPC response must be a JSON object")
206
+ return payload
207
+
208
+ def _parse_sse_response(self, raw: str) -> Dict[str, Any]:
209
+ for event in raw.split("\n\n"):
210
+ data_lines = []
211
+ for line in event.splitlines():
212
+ if line.startswith("data:"):
213
+ data_lines.append(line[5:].lstrip())
214
+ if not data_lines:
215
+ continue
216
+ payload = json.loads("\n".join(data_lines))
217
+ if isinstance(payload, dict) and ("result" in payload or "error" in payload):
218
+ return payload
219
+ raise RuntimeError("SSE stream ended without a JSON-RPC response")
220
+
221
+
151
222
  class ECPRunner:
152
223
  """The Orchestrator."""
153
224
 
@@ -163,7 +234,7 @@ class ECPRunner:
163
234
  logger.info("Scenario: %s", scenario.name)
164
235
 
165
236
  rpc_timeout = float(os.environ.get("ECP_RPC_TIMEOUT", "30"))
166
- agent = AgentProcess(self.manifest.target, rpc_timeout=rpc_timeout)
237
+ agent = self._create_agent(self.manifest.target, rpc_timeout=rpc_timeout)
167
238
  agent.start()
168
239
 
169
240
  try:
@@ -225,6 +296,11 @@ class ECPRunner:
225
296
  "scenarios": report_data
226
297
  }
227
298
 
299
+ def _create_agent(self, target: str, rpc_timeout: float):
300
+ if _is_http_url(target):
301
+ return HTTPAgentClient(target, rpc_timeout=rpc_timeout)
302
+ return AgentProcess(target, rpc_timeout=rpc_timeout)
303
+
228
304
  def _ensure_rpc_success(
229
305
  self,
230
306
  rpc_resp: Dict[str, Any],
@@ -244,3 +320,8 @@ class ECPRunner:
244
320
  raise RuntimeError(
245
321
  f"RPC call failed ({method}) at {where}: code={code}, message={message}"
246
322
  )
323
+
324
+
325
+ def _is_http_url(target: str) -> bool:
326
+ parsed = urlparse(target)
327
+ return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
@@ -0,0 +1,135 @@
1
+ import os
2
+ import socket
3
+ import subprocess
4
+ import sys
5
+ import tempfile
6
+ import textwrap
7
+ import time
8
+ import unittest
9
+ from pathlib import Path
10
+
11
+ REPO_ROOT = Path(__file__).resolve().parents[3]
12
+ RUNTIME_SRC = Path(__file__).resolve().parents[1] / "src"
13
+
14
+
15
+ class ExampleIntegrationTests(unittest.TestCase):
16
+ def _run_manifest(self, manifest_path: Path) -> subprocess.CompletedProcess[str]:
17
+ env = dict(os.environ)
18
+ existing = env.get("PYTHONPATH", "")
19
+ extra_paths = [str(RUNTIME_SRC)]
20
+ if existing:
21
+ extra_paths.append(existing)
22
+ env["PYTHONPATH"] = os.pathsep.join(extra_paths)
23
+
24
+ return subprocess.run(
25
+ [
26
+ sys.executable,
27
+ "-m",
28
+ "ecp_runtime.cli",
29
+ "run",
30
+ "--manifest",
31
+ str(manifest_path),
32
+ "--json",
33
+ ],
34
+ cwd=REPO_ROOT,
35
+ text=True,
36
+ capture_output=True,
37
+ env=env,
38
+ check=False,
39
+ )
40
+
41
+ def test_plain_python_demo_manifest_passes(self) -> None:
42
+ manifest = REPO_ROOT / "examples" / "plain_python_demo" / "manifest.yaml"
43
+ result = self._run_manifest(manifest)
44
+ self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
45
+ self.assertIn('"failed": 0', result.stdout)
46
+
47
+ def test_two_agent_demo_manifest_passes(self) -> None:
48
+ manifest = REPO_ROOT / "examples" / "two_agent_demo" / "manifest.yaml"
49
+ result = self._run_manifest(manifest)
50
+ self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
51
+ self.assertIn('"failed": 0', result.stdout)
52
+
53
+ def test_streamable_http_demo_manifest_passes(self) -> None:
54
+ port = self._free_port()
55
+ server_env = dict(os.environ)
56
+ sdk_src = REPO_ROOT / "sdk" / "python" / "src"
57
+ existing = server_env.get("PYTHONPATH", "")
58
+ extra_paths = [str(sdk_src)]
59
+ if existing:
60
+ extra_paths.append(existing)
61
+ server_env["PYTHONPATH"] = os.pathsep.join(extra_paths)
62
+ server_env["ECP_HTTP_PORT"] = str(port)
63
+
64
+ server = subprocess.Popen(
65
+ [sys.executable, str(REPO_ROOT / "examples" / "streamable_http_demo" / "agent.py")],
66
+ cwd=REPO_ROOT,
67
+ text=True,
68
+ stdout=subprocess.PIPE,
69
+ stderr=subprocess.PIPE,
70
+ env=server_env,
71
+ )
72
+
73
+ try:
74
+ self._wait_for_port("127.0.0.1", port)
75
+ with tempfile.TemporaryDirectory() as tmpdir:
76
+ manifest_path = Path(tmpdir) / "streamable-http-manifest.yaml"
77
+ manifest_path.write_text(
78
+ textwrap.dedent(
79
+ f"""
80
+ manifest_version: "v1"
81
+ name: "Streamable HTTP Transport Validation"
82
+ target: "http://127.0.0.1:{port}/ecp"
83
+
84
+ scenarios:
85
+ - name: "HTTP Echo"
86
+ steps:
87
+ - input: "echo: transport is online"
88
+ graders:
89
+ - type: text_match
90
+ field: public_output
91
+ condition: contains
92
+ value: "transport is online"
93
+ - type: tool_usage
94
+ tool_name: "http_echo"
95
+ arguments:
96
+ message: "transport is online"
97
+ """
98
+ ).strip(),
99
+ encoding="utf-8",
100
+ )
101
+ result = self._run_manifest(manifest_path)
102
+
103
+ self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
104
+ self.assertIn('"failed": 0', result.stdout)
105
+ finally:
106
+ server.terminate()
107
+ try:
108
+ server.communicate(timeout=2)
109
+ except subprocess.TimeoutExpired:
110
+ server.kill()
111
+ server.communicate()
112
+
113
+ def _free_port(self) -> int:
114
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
115
+ sock.bind(("127.0.0.1", 0))
116
+ return int(sock.getsockname()[1])
117
+
118
+ def _wait_for_port(self, host: str, port: int) -> None:
119
+ deadline = time.time() + 5
120
+ while time.time() < deadline:
121
+ if self._port_is_open(host, port):
122
+ return
123
+ time.sleep(0.05)
124
+ raise AssertionError(f"Timed out waiting for {host}:{port}")
125
+
126
+ def _port_is_open(self, host: str, port: int) -> bool:
127
+ try:
128
+ with socket.create_connection((host, port), timeout=0.2):
129
+ return True
130
+ except OSError:
131
+ return False
132
+
133
+
134
+ if __name__ == "__main__":
135
+ unittest.main()
@@ -9,7 +9,7 @@ if str(RUNTIME_SRC) not in sys.path:
9
9
  sys.path.insert(0, str(RUNTIME_SRC))
10
10
 
11
11
  from ecp_runtime.manifest import StepConfig
12
- from ecp_runtime.runner import ECPRunner
12
+ from ecp_runtime.runner import ECPRunner, HTTPAgentClient
13
13
 
14
14
 
15
15
  class RunnerTests(unittest.TestCase):
@@ -68,6 +68,50 @@ class RunnerTests(unittest.TestCase):
68
68
  self.assertIn("step=1", msg)
69
69
  self.assertIn("boom", msg)
70
70
 
71
+ def test_runner_uses_http_client_for_url_target(self) -> None:
72
+ runner = ECPRunner(SimpleNamespace(target="http://127.0.0.1:8765/ecp", scenarios=[]))
73
+
74
+ agent = runner._create_agent(runner.manifest.target, rpc_timeout=12.0)
75
+
76
+ self.assertIsInstance(agent, HTTPAgentClient)
77
+ self.assertEqual(agent.endpoint, "http://127.0.0.1:8765/ecp")
78
+ self.assertEqual(agent.rpc_timeout, 12.0)
79
+
80
+ def test_http_agent_client_posts_json_rpc(self) -> None:
81
+ class FakeResponse:
82
+ headers = {"Content-Type": "application/json; charset=utf-8"}
83
+
84
+ def __enter__(self):
85
+ return self
86
+
87
+ def __exit__(self, *_args):
88
+ return None
89
+
90
+ def read(self):
91
+ return b'{"jsonrpc":"2.0","id":1,"result":{"ok":true}}'
92
+
93
+ captured = {}
94
+
95
+ def fake_urlopen(req, timeout):
96
+ captured["url"] = req.full_url
97
+ captured["timeout"] = timeout
98
+ captured["body"] = req.data
99
+ captured["accept"] = req.headers.get("Accept")
100
+ return FakeResponse()
101
+
102
+ with mock.patch("ecp_runtime.runner.request.urlopen", fake_urlopen):
103
+ response = HTTPAgentClient("http://agent.test/ecp", rpc_timeout=3).send_rpc(
104
+ "agent/step", {"input": "hi"}
105
+ )
106
+
107
+ self.assertEqual(response["result"]["ok"], True)
108
+ self.assertEqual(captured["url"], "http://agent.test/ecp")
109
+ self.assertEqual(captured["timeout"], 3)
110
+ self.assertIn("application/json", captured["accept"])
111
+ body = captured["body"].decode("utf-8")
112
+ self.assertIn('"method": "agent/step"', body)
113
+ self.assertIn('"input": "hi"', body)
114
+
71
115
 
72
116
  if __name__ == "__main__":
73
117
  unittest.main()
@@ -1,51 +0,0 @@
1
- import os
2
- import subprocess
3
- import sys
4
- import unittest
5
- from pathlib import Path
6
-
7
- REPO_ROOT = Path(__file__).resolve().parents[3]
8
- RUNTIME_SRC = Path(__file__).resolve().parents[1] / "src"
9
-
10
-
11
- class ExampleIntegrationTests(unittest.TestCase):
12
- def _run_manifest(self, manifest_path: Path) -> subprocess.CompletedProcess[str]:
13
- env = dict(os.environ)
14
- existing = env.get("PYTHONPATH", "")
15
- extra_paths = [str(RUNTIME_SRC)]
16
- if existing:
17
- extra_paths.append(existing)
18
- env["PYTHONPATH"] = os.pathsep.join(extra_paths)
19
-
20
- return subprocess.run(
21
- [
22
- sys.executable,
23
- "-m",
24
- "ecp_runtime.cli",
25
- "run",
26
- "--manifest",
27
- str(manifest_path),
28
- "--json",
29
- ],
30
- cwd=REPO_ROOT,
31
- text=True,
32
- capture_output=True,
33
- env=env,
34
- check=False,
35
- )
36
-
37
- def test_plain_python_demo_manifest_passes(self) -> None:
38
- manifest = REPO_ROOT / "examples" / "plain_python_demo" / "manifest.yaml"
39
- result = self._run_manifest(manifest)
40
- self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
41
- self.assertIn('"failed": 0', result.stdout)
42
-
43
- def test_two_agent_demo_manifest_passes(self) -> None:
44
- manifest = REPO_ROOT / "examples" / "two_agent_demo" / "manifest.yaml"
45
- result = self._run_manifest(manifest)
46
- self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
47
- self.assertIn('"failed": 0', result.stdout)
48
-
49
-
50
- if __name__ == "__main__":
51
- unittest.main()