hidevs-eval 0.1.1__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.
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: hidevs-eval
3
+ Version: 0.1.1
4
+ Summary: HiDevs quest eval client. Runs the local agent and uploads replies for scoring.
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: pyyaml>=6
@@ -0,0 +1,115 @@
1
+ # hidevs-eval
2
+
3
+ Laptop client for the HiDevs quest evaluation. Python 3.11 or newer is required.
4
+
5
+ This package runs your agent and uploads the replies. The server at `https://cli.server.hidevs.xyz` sends the inputs, scores the replies, and stores the run. This repo does not contain the server.
6
+
7
+ ## Install
8
+
9
+ From PyPI (after this package is published):
10
+
11
+ ```bash
12
+ pip install -U hidevs-eval
13
+ hidevs-eval version
14
+ ```
15
+
16
+ `hidevs-eval version` should print `0.1.1`.
17
+
18
+ Until PyPI is live, or if you need the git copy:
19
+
20
+ ```bash
21
+ pip install -U "git+https://github.com/hidevscommunity/hidevs-eval-cli.git"
22
+ ```
23
+
24
+ The git install needs access to that repository.
25
+
26
+ ## User flow
27
+
28
+ 1. Install the client and confirm it with `hidevs-eval version`.
29
+ 2. Take the platform token from the quest page. After you select a problem, the token looks like `HQ-1A2B3C4D`. That token is the login code. It names you, the quest, and the problem statement.
30
+ 3. Copy the sample file to `agent_config.yaml` in the root of your agent repository. Fill in how your program starts, which field receives each problem input, and which field in the reply holds each problem output. Leave a field out when the program has nowhere to read or write it.
31
+ 4. From that same folder, log in and paste the token:
32
+
33
+ ```bash
34
+ hidevs-eval login
35
+ ```
36
+
37
+ The login is saved in `~/.hidevs_quest/session.json`. The CLI prints the user, the quest, and the problem statement.
38
+
39
+ 5. Optional: check that this Python can import the entry and tools from `agent_config.yaml` (not a scored check):
40
+
41
+ ```bash
42
+ hidevs-eval preflight --config agent_config.yaml
43
+ ```
44
+
45
+ `eval` runs the same check automatically before Level 1.
46
+
47
+ 6. Run all three levels:
48
+
49
+ ```bash
50
+ hidevs-eval eval --config agent_config.yaml
51
+ ```
52
+
53
+ Running again stores another result. Earlier results stay.
54
+
55
+ ## Commands
56
+
57
+ ```bash
58
+ hidevs-eval help
59
+ hidevs-eval version
60
+ hidevs-eval login
61
+ hidevs-eval whoami
62
+ hidevs-eval preflight --config agent_config.yaml
63
+ hidevs-eval eval --config agent_config.yaml
64
+ hidevs-eval logout
65
+ ```
66
+
67
+ `--level 1`, `--level 2`, or `--level 3` runs only that level.
68
+
69
+ ```bash
70
+ hidevs-eval server show
71
+ hidevs-eval server set https://cli.server.hidevs.xyz
72
+ ```
73
+
74
+ `server set` clears the saved login. Run `login` again after changing the link.
75
+
76
+ ## What is evaluated
77
+
78
+ Level 1 runs on all five cases for your problem statement.
79
+
80
+ - L1.1 the file matches the schema and names the required fields
81
+ - L1.2 the declared entry returned
82
+ - L1.3 the shared input fits the declared input schema
83
+ - L1.4 the declared output paths exist
84
+ - L1.5 the declared normalizers match the expected values
85
+ - L1.6 the shared expected values match
86
+ - L1.7 forbidden values from the input are absent from the reply
87
+ - L1.8 the response matches the declared schema and the problem limits
88
+ - L1.9 the required tools were called
89
+ - L1.10 the required framework is declared and present in the code
90
+
91
+ Level 2 uses the first two of those cases.
92
+
93
+ - L2.1 wrong type on the first case. The run must report the failure status, and the required tools must not be called
94
+ - L2.2 wrong type on the second case
95
+ - L2.3 empty value on the first case
96
+ - L2.4 empty value on the second case
97
+ - L2.5 every required tool raises for the whole first case. The run must report the failure status
98
+ - L2.6 every required tool raises for the whole second case
99
+ - L2.7 every required tool times out for the whole first case. The run must report the failure status
100
+ - L2.8 every required tool times out for the whole second case
101
+ - L2.9 the required tools run on the first case
102
+ - L2.10 the required tools run on the second case
103
+
104
+ Level 3 uses the same first two cases. The server scores each reply.
105
+
106
+ - L3.1 hallucination on the first case
107
+ - L3.2 hallucination on the second case
108
+ - L3.3 answer relevancy on the first case
109
+ - L3.4 answer relevancy on the second case
110
+ - L3.5 problem rubric on the first case
111
+ - L3.6 problem rubric on the second case
112
+ - L3.7 PII on the first case
113
+ - L3.8 PII on the second case
114
+ - L3.9 bias on the first case
115
+ - L3.10 bias on the second case
@@ -0,0 +1,18 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hidevs-eval"
7
+ version = "0.1.1"
8
+ description = "HiDevs quest eval client. Runs the local agent and uploads replies for scoring."
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "pyyaml>=6",
12
+ ]
13
+
14
+ [project.scripts]
15
+ hidevs-eval = "hidevs_quest.cli:main"
16
+
17
+ [tool.setuptools.packages.find]
18
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: hidevs-eval
3
+ Version: 0.1.1
4
+ Summary: HiDevs quest eval client. Runs the local agent and uploads replies for scoring.
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: pyyaml>=6
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/hidevs_eval.egg-info/PKG-INFO
4
+ src/hidevs_eval.egg-info/SOURCES.txt
5
+ src/hidevs_eval.egg-info/dependency_links.txt
6
+ src/hidevs_eval.egg-info/entry_points.txt
7
+ src/hidevs_eval.egg-info/requires.txt
8
+ src/hidevs_eval.egg-info/top_level.txt
9
+ src/hidevs_quest/__init__.py
10
+ src/hidevs_quest/cli.py
11
+ src/hidevs_quest/runner.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hidevs-eval = hidevs_quest.cli:main
@@ -0,0 +1 @@
1
+ pyyaml>=6
@@ -0,0 +1 @@
1
+ hidevs_quest
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
@@ -0,0 +1,332 @@
1
+ """hidevs-eval login, then hidevs-eval eval. The server sends the cases and stores the score."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ import urllib.error
9
+ from pathlib import Path
10
+
11
+ import yaml
12
+
13
+ from hidevs_quest import __version__
14
+ from hidevs_quest.runner import collect_sources, get_json, post_json, preflight, run_cases
15
+
16
+ SESSION = Path.home() / ".hidevs_quest" / "session.json"
17
+
18
+
19
+ def load_session() -> dict:
20
+ if not SESSION.exists():
21
+ return {}
22
+ return json.loads(SESSION.read_text())
23
+
24
+
25
+ def save_session(session: dict) -> None:
26
+ SESSION.parent.mkdir(parents=True, exist_ok=True)
27
+ SESSION.write_text(json.dumps(session, indent=2))
28
+
29
+
30
+ def server_url(args: argparse.Namespace) -> str:
31
+ return (args.server or load_session().get("server") or "https://cli.server.hidevs.xyz").rstrip("/")
32
+
33
+
34
+ def api(url: str, payload: dict, token: str = "") -> dict:
35
+ try:
36
+ return post_json(url, payload, token)
37
+ except urllib.error.HTTPError as exc:
38
+ detail = exc.read().decode()
39
+ try:
40
+ detail = json.loads(detail).get("detail", detail)
41
+ except json.JSONDecodeError:
42
+ pass
43
+ raise SystemExit(f"{exc.code} {detail}") from exc
44
+
45
+
46
+ def version_key(value: str) -> tuple[int, ...]:
47
+ parts = []
48
+ for part in value.split("."):
49
+ digits = "".join(character for character in part if character.isdigit())
50
+ parts.append(int(digits or "0"))
51
+ return tuple(parts)
52
+
53
+
54
+ def client_notice(base: str) -> dict:
55
+ try:
56
+ settings = get_json(f"{base}/v1/settings")
57
+ except Exception as exc:
58
+ raise SystemExit(f"could not reach {base}: {exc}") from exc
59
+ if version_key(__version__) < version_key(settings.get("min_version") or "0.0.0"):
60
+ extra = f" {settings['message']}" if settings.get("message") else ""
61
+ raise SystemExit(
62
+ f"this install is {__version__}. The server requires {settings['min_version']}.{extra}"
63
+ )
64
+ if version_key(settings.get("latest_version") or "0.0.0") > version_key(__version__):
65
+ print(f"a newer package is published: {settings['latest_version']}. You have {__version__}.")
66
+ if settings.get("message"):
67
+ print(settings["message"])
68
+ published = (settings.get("server_url") or "").rstrip("/")
69
+ if published and published != base:
70
+ print(f"the published server link is {published}")
71
+ print(f"switch with: hidevs-eval server set {published}")
72
+ return settings
73
+
74
+
75
+ def login(args: argparse.Namespace) -> dict:
76
+ previous = load_session()
77
+ code = args.code or input("Platform code: ").strip()
78
+ if not code:
79
+ raise SystemExit("a platform code is required")
80
+ base = server_url(args)
81
+ client_notice(base)
82
+ body = api(f"{base}/v1/auth", {"code": code})
83
+ if previous.get("user_id") and previous.get("user_id") != body["user_id"]:
84
+ print(f"replaced login for {previous['user_id']}")
85
+ session = {
86
+ "server": base,
87
+ "token": body["token"],
88
+ "user_id": body["user_id"],
89
+ "quest_id": body.get("quest_id") or "",
90
+ "ps_id": body["ps_id"],
91
+ }
92
+ save_session(session)
93
+ print(f"validated for user {body['user_id']} quest {session['quest_id']} problem {body['ps_id']}")
94
+ return session
95
+
96
+
97
+ def logout(_: argparse.Namespace) -> None:
98
+ if SESSION.exists():
99
+ SESSION.unlink()
100
+ print("logged out")
101
+
102
+
103
+ def whoami(args: argparse.Namespace) -> None:
104
+ session = load_session()
105
+ print(f"package {__version__}")
106
+ base = server_url(args)
107
+ print(f"server {base}")
108
+ if not session.get("token"):
109
+ print("not logged in")
110
+ return
111
+ print(f"user {session.get('user_id')} quest {session.get('quest_id')} problem {session.get('ps_id')}")
112
+ client_notice(base)
113
+
114
+
115
+ def show_server(args: argparse.Namespace) -> None:
116
+ print(server_url(args))
117
+
118
+
119
+ def set_server(args: argparse.Namespace) -> None:
120
+ url = args.url.rstrip("/")
121
+ session = load_session()
122
+ session["server"] = url
123
+ session.pop("token", None)
124
+ session.pop("user_id", None)
125
+ session.pop("quest_id", None)
126
+ session.pop("ps_id", None)
127
+ save_session(session)
128
+ print(f"server set to {url}")
129
+ print("log in again with the platform code")
130
+
131
+
132
+ HELP = """
133
+ hidevs-eval login ask for the platform code and save it
134
+ hidevs-eval login --code CODE save a different code, replacing the previous login
135
+ hidevs-eval logout forget the saved login
136
+ hidevs-eval whoami show the package version, server, and user
137
+ hidevs-eval preflight import entry and tools from agent_config.yaml
138
+ hidevs-eval eval run level 1, level 2, and level 3
139
+ hidevs-eval eval --level 1 --config agent_config.yaml
140
+ hidevs-eval server show print the server this laptop uses
141
+ hidevs-eval server set URL point this laptop at a different server
142
+ hidevs-eval version print the installed package version
143
+ hidevs-eval help show this list
144
+ """.strip()
145
+
146
+
147
+ def show_help(_: argparse.Namespace) -> None:
148
+ print(HELP)
149
+
150
+
151
+ def show_version(_: argparse.Namespace) -> None:
152
+ print(__version__)
153
+
154
+
155
+ ARENA_URL = "https://app.hidevs.xyz/ai-quest/challenges/ps-lyzr-01/arena"
156
+ LEVEL_HEADINGS = {
157
+ 1: "LEVEL 1: DETERMINISTIC PROPERTY ASSERTIONS",
158
+ 2: "LEVEL 2: INPUT AND TOOL QUALITY",
159
+ 3: "LEVEL 3: LLM JUDGE",
160
+ }
161
+
162
+
163
+ def check_score(check: dict) -> float:
164
+ evidence = check.get("evidence")
165
+ raw = evidence.get("score") if isinstance(evidence, dict) else None
166
+ if isinstance(raw, (int, float)):
167
+ return max(0.0, min(1.0, float(raw)))
168
+ return 1.0 if check.get("status") == "PASS" else 0.0
169
+
170
+
171
+ def print_result(results: list[dict]) -> None:
172
+ line = "=" * 80
173
+ first = results[0] if results else {}
174
+ name = first.get("problem_name") or first.get("ps_id") or "Quest evaluation"
175
+ summary_text = first.get("problem_summary") or ""
176
+ print(line)
177
+ print("BENCHMARK: HiDevs AI Quest Evaluation")
178
+ print(f"PROBLEM STATEMENT: {name}")
179
+ if summary_text:
180
+ print(summary_text)
181
+ print(line)
182
+ rows = []
183
+ passed_all = 0
184
+ total_all = 0
185
+ for result in results:
186
+ print()
187
+ level = result.get("level")
188
+ print(f"[{LEVEL_HEADINGS.get(level, f'LEVEL {level}')}]")
189
+ scores = []
190
+ passed = 0
191
+ for check in result.get("checks") or []:
192
+ score = check_score(check)
193
+ scores.append(score)
194
+ status = check.get("status") or "FAIL"
195
+ if status == "PASS":
196
+ passed += 1
197
+ mark = "PASS" if status == "PASS" else status
198
+ evidence = check.get("evidence") if isinstance(check.get("evidence"), dict) else {}
199
+ reason = (check.get("reason") or "").split("\n")[0]
200
+ if level == 3:
201
+ title = f"{evidence.get('metric', '')} ({evidence.get('case', '')})".strip()
202
+ print(f" [{mark}] {check.get('id', ''):<24} | Score: {score:.2f} | {title}")
203
+ if reason:
204
+ print(f" * Reason: {reason}")
205
+ else:
206
+ print(f" [{mark}] {check.get('id', ''):<24} | Score: {score:.2f} | {reason}")
207
+ total = len(scores)
208
+ level_score = (sum(scores) / total * 100.0) if total else 0.0
209
+ rows.append((LEVEL_HEADINGS.get(level, f"Level {level}"), passed, total, level_score))
210
+ passed_all += passed
211
+ total_all += total
212
+ composite = (sum(row[3] for row in rows) / len(rows)) if rows else 0.0
213
+ print()
214
+ print(line)
215
+ print("FINAL BENCHMARK SCORECARD")
216
+ print(line)
217
+ for heading, passed, total, level_score in rows:
218
+ print(f"{heading:<42}: Passed {passed}/{total:<2} | Level Score: {level_score:.1f}%")
219
+ print("-" * 80)
220
+ print(f"TOTAL SYSTEM AGENT COMPOSITE SCORE : {composite:.1f}% ({passed_all}/{total_all} Passed)")
221
+ print(line)
222
+ print()
223
+ print(f"For the detailed report, open {ARENA_URL}")
224
+
225
+
226
+ def run_preflight(config_path: Path) -> dict:
227
+ if not config_path.exists():
228
+ raise SystemExit(f"missing {config_path}")
229
+ config = yaml.safe_load(config_path.read_text()) or {}
230
+ root = config_path.parent
231
+ errors = preflight(config, root)
232
+ if errors:
233
+ print("preflight failed (not a scored check)")
234
+ for item in errors:
235
+ print(f" - {item}")
236
+ raise SystemExit(1)
237
+ entry = config.get("entry") or {}
238
+ if entry.get("type") == "http":
239
+ label = f"http {entry.get('app')}"
240
+ elif "class" in entry:
241
+ label = f"python class {entry.get('class')}.{entry.get('method')}"
242
+ else:
243
+ label = f"python callable {entry.get('callable')}"
244
+ print(f"preflight ok: {label}")
245
+ tools = config.get("tools") or []
246
+ if tools:
247
+ print(f"preflight ok: {len(tools)} tool function(s) importable")
248
+ return config
249
+
250
+
251
+ def preflight_command(args: argparse.Namespace) -> None:
252
+ run_preflight(Path(args.config).resolve())
253
+
254
+
255
+ def evaluate(args: argparse.Namespace) -> None:
256
+ session = load_session()
257
+ if not session.get("token") or server_url(args) != session.get("server"):
258
+ session = login(args)
259
+ else:
260
+ client_notice(session["server"])
261
+ config_path = Path(args.config).resolve()
262
+ config = run_preflight(config_path)
263
+ root = config_path.parent
264
+ levels = [args.level] if args.level else [1, 2, 3]
265
+ reports = []
266
+ sources = collect_sources(root)
267
+ for level in levels:
268
+ started = api(
269
+ f"{session['server']}/v1/levels/{level}/start",
270
+ {"config": config, "sources": sources},
271
+ session["token"],
272
+ )
273
+ if started.get("result"):
274
+ reports.append(started["result"])
275
+ continue
276
+ results = run_cases(config, root, started["cases"])
277
+ reports.append(api(
278
+ f"{session['server']}/v1/runs/{started['run_id']}",
279
+ {"results": results},
280
+ session["token"],
281
+ ))
282
+ print_result(reports)
283
+
284
+
285
+ def main() -> None:
286
+ parser = argparse.ArgumentParser(prog="hidevs-eval", description=HELP, formatter_class=argparse.RawDescriptionHelpFormatter)
287
+ parser.add_argument("--server", default="", help="eval server for this command")
288
+ sub = parser.add_subparsers(dest="command", required=True)
289
+
290
+ help_parser = sub.add_parser("help", help="list the commands")
291
+ help_parser.set_defaults(func=show_help)
292
+
293
+ version_parser = sub.add_parser("version", help="print the installed package version")
294
+ version_parser.set_defaults(func=show_version)
295
+
296
+ login_parser = sub.add_parser("login", help="save a platform code, replacing any previous login")
297
+ login_parser.add_argument("--code", default="")
298
+ login_parser.set_defaults(func=login)
299
+
300
+ logout_parser = sub.add_parser("logout", help="forget the saved login")
301
+ logout_parser.set_defaults(func=logout)
302
+
303
+ whoami_parser = sub.add_parser("whoami", help="show version, server, and user")
304
+ whoami_parser.set_defaults(func=whoami)
305
+
306
+ server_parser = sub.add_parser("server", help="show or change the server link")
307
+ server_sub = server_parser.add_subparsers(dest="server_command", required=True)
308
+ server_show = server_sub.add_parser("show", help="print the server this laptop uses")
309
+ server_show.set_defaults(func=show_server)
310
+ server_set = server_sub.add_parser("set", help="point this laptop at a different server")
311
+ server_set.add_argument("url")
312
+ server_set.set_defaults(func=set_server)
313
+
314
+ preflight_parser = sub.add_parser("preflight", help="import entry and tools before scoring")
315
+ preflight_parser.add_argument("--config", default="agent_config.yaml")
316
+ preflight_parser.set_defaults(func=preflight_command)
317
+
318
+ eval_parser = sub.add_parser("eval", help="pull cases, run the local agent, upload replies")
319
+ eval_parser.add_argument("--config", default="agent_config.yaml")
320
+ eval_parser.add_argument("--level", type=int, default=None)
321
+ eval_parser.add_argument("--code", default="")
322
+ eval_parser.set_defaults(func=evaluate)
323
+
324
+ args = parser.parse_args()
325
+ try:
326
+ args.func(args)
327
+ except KeyboardInterrupt:
328
+ sys.exit(130)
329
+
330
+
331
+ if __name__ == "__main__":
332
+ main()
@@ -0,0 +1,243 @@
1
+ """Call the local agent. The server never sees this process."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import json
7
+ import sys
8
+ import traceback
9
+ import urllib.request
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ SKIP_DIRS = {".venv", "venv", ".git", "__pycache__", "node_modules", ".hidevs"}
15
+ MAX_FILE_BYTES = 200_000
16
+ MAX_TOTAL_BYTES = 1_500_000
17
+
18
+
19
+ def as_dict(value: Any) -> dict:
20
+ if hasattr(value, "model_dump"):
21
+ return value.model_dump()
22
+ if isinstance(value, dict):
23
+ return value
24
+ return {"_raw": str(value)}
25
+
26
+
27
+ def collect_sources(root: Path) -> dict[str, str]:
28
+ sources: dict[str, str] = {}
29
+ total = 0
30
+ for path in root.rglob("*.py"):
31
+ if any(part in SKIP_DIRS for part in path.parts):
32
+ continue
33
+ if path.stat().st_size > MAX_FILE_BYTES:
34
+ continue
35
+ text = path.read_text(errors="ignore")
36
+ total += len(text)
37
+ if total > MAX_TOTAL_BYTES:
38
+ break
39
+ sources[path.relative_to(root).as_posix()] = text
40
+ return sources
41
+
42
+
43
+ def build_kwargs(config: dict, case: dict) -> dict:
44
+ entry = config["entry"]
45
+ kwargs = dict(entry.get("constants") or {})
46
+ for source, target in (entry.get("arguments") or {}).items():
47
+ if source in case["input"]:
48
+ kwargs[target] = case["input"][source]
49
+ return kwargs
50
+
51
+
52
+ def _load_symbol(root: Path, spec: str):
53
+ if ":" not in spec:
54
+ raise ValueError(f"expected module:name, got {spec!r}")
55
+ module_name, attr = spec.split(":", 1)
56
+ if str(root) not in sys.path:
57
+ sys.path.insert(0, str(root))
58
+ module = importlib.import_module(module_name)
59
+ if not hasattr(module, attr):
60
+ raise AttributeError(f"{module_name} has no attribute {attr}")
61
+ return getattr(module, attr)
62
+
63
+
64
+ def preflight(config: dict, root: Path) -> list[str]:
65
+ """Import the entry and tools from agent_config.yaml. Not a scored check."""
66
+ errors: list[str] = []
67
+ root = root.resolve()
68
+ entry = config.get("entry") or {}
69
+ try:
70
+ if entry.get("type") == "http":
71
+ app = entry.get("app") or ""
72
+ _load_symbol(root, app)
73
+ if not entry.get("method") or not entry.get("endpoint"):
74
+ errors.append("entry.method and entry.endpoint are required for http")
75
+ elif "class" in entry:
76
+ cls = _load_symbol(root, entry["class"])
77
+ method_name = entry.get("method") or ""
78
+ if not method_name:
79
+ errors.append("entry.method is required for a python class")
80
+ elif not callable(getattr(cls, method_name, None)):
81
+ errors.append(f"class {entry['class']} has no callable method {method_name}")
82
+ elif "callable" in entry:
83
+ fn = _load_symbol(root, entry["callable"])
84
+ if not callable(fn):
85
+ errors.append(f"entry.callable {entry['callable']} is not callable")
86
+ else:
87
+ errors.append("entry must declare http app, python class, or python callable")
88
+ except Exception as exc:
89
+ errors.append(f"entry could not be imported: {exc}")
90
+
91
+ for tool in config.get("tools") or []:
92
+ spec = tool.get("function") or ""
93
+ capability = tool.get("capability") or spec
94
+ if not spec:
95
+ errors.append(f"tool {capability} is missing function")
96
+ continue
97
+ try:
98
+ fn = _load_symbol(root, spec)
99
+ if not callable(fn):
100
+ errors.append(f"tool {capability} function {spec} is not callable")
101
+ except Exception as exc:
102
+ errors.append(f"tool {capability} could not be imported ({spec}): {exc}")
103
+
104
+ req = root / "requirements.txt"
105
+ if req.is_file() and errors:
106
+ errors.append(
107
+ f"install agent libraries in this same Python, then retry: "
108
+ f"pip install -r {req}"
109
+ )
110
+ elif errors:
111
+ errors.append(
112
+ "install agent libraries in the same Python that runs hidevs-eval, then retry"
113
+ )
114
+ return errors
115
+
116
+
117
+ def invoke(config: dict, root: Path, case: dict) -> Any:
118
+ entry = config["entry"]
119
+ kwargs = build_kwargs(config, case)
120
+ if entry.get("type") == "http":
121
+ application = _load_symbol(root, entry["app"])
122
+ from fastapi.testclient import TestClient
123
+
124
+ client = TestClient(application)
125
+ response = client.request(entry["method"], entry["endpoint"], json=kwargs)
126
+ response.raise_for_status()
127
+ return response.json()
128
+ if "class" in entry:
129
+ cls = _load_symbol(root, entry["class"])
130
+ method = getattr(cls(), entry["method"])
131
+ return method(**kwargs)
132
+ fn = _load_symbol(root, entry["callable"])
133
+ return fn(**kwargs)
134
+
135
+
136
+ class Scripted:
137
+ def __init__(self, original, mode: str):
138
+ self.original = original
139
+ self.mode = mode
140
+ self.calls = []
141
+
142
+ def __call__(self, *args, **kwargs):
143
+ self.calls.append(True)
144
+ if self.mode == "raise":
145
+ raise RuntimeError("injected tool failure")
146
+ if self.mode == "timeout":
147
+ raise TimeoutError("injected tool timeout")
148
+ return self.original(*args, **kwargs)
149
+
150
+
151
+ def modules_under(root: Path):
152
+ root = root.resolve()
153
+ for module in list(sys.modules.values()):
154
+ path = getattr(module, "__file__", None)
155
+ if not path:
156
+ continue
157
+ try:
158
+ if Path(path).resolve().is_relative_to(root):
159
+ yield module
160
+ except (OSError, ValueError):
161
+ continue
162
+
163
+
164
+ def patch_tools(root: Path, config: dict, mode: str):
165
+ wrappers = {}
166
+ restores = []
167
+ mode = mode if mode in {"raise", "timeout"} else "ok"
168
+ sys.path.insert(0, str(root))
169
+ for tool in config.get("tools") or []:
170
+ capability = tool.get("capability")
171
+ spec = tool.get("function") or ""
172
+ if not capability or ":" not in spec:
173
+ continue
174
+ module_name, fn_name = spec.split(":", 1)
175
+ module = __import__(module_name, fromlist=[fn_name])
176
+ original = getattr(module, fn_name)
177
+ wrapper = Scripted(original, mode)
178
+ wrappers[capability] = wrapper
179
+ setattr(module, fn_name, wrapper)
180
+ restores.append((module, fn_name, original))
181
+ for loaded in modules_under(root):
182
+ for attr, value in list(vars(loaded).items()):
183
+ if value is original:
184
+ setattr(loaded, attr, wrapper)
185
+ restores.append((loaded, attr, original))
186
+ if isinstance(value, dict):
187
+ for key, item in list(value.items()):
188
+ if item is original:
189
+ value[key] = wrapper
190
+ restores.append((value, key, original))
191
+ return wrappers, restores
192
+
193
+
194
+ def restore(restores) -> None:
195
+ for owner, key, original in reversed(restores):
196
+ if isinstance(owner, dict):
197
+ owner[key] = original
198
+ else:
199
+ setattr(owner, key, original)
200
+
201
+
202
+ def run_cases(config: dict, root: Path, cases: list[dict]) -> list[dict]:
203
+ results = []
204
+ for case in cases:
205
+ mode = case.get("tool_mode") or "ok"
206
+ wrappers, restores = patch_tools(root, config, mode)
207
+ try:
208
+ try:
209
+ payload = as_dict(invoke(config, root, case))
210
+ error = ""
211
+ except Exception:
212
+ payload = {}
213
+ error = traceback.format_exc()
214
+ finally:
215
+ restore(restores)
216
+ counts = {name: len(wrapper.calls) for name, wrapper in wrappers.items()}
217
+ results.append({
218
+ "id": case["id"],
219
+ "payload": payload,
220
+ "error": error[-1500:] if error else "",
221
+ "counts": counts,
222
+ })
223
+ return results
224
+
225
+
226
+ CLIENT_HEADER = "X-Hidevs-Client"
227
+ CLIENT_VALUE = "hidevs-eval"
228
+
229
+
230
+ def get_json(url: str) -> dict:
231
+ request = urllib.request.Request(url, headers={CLIENT_HEADER: CLIENT_VALUE})
232
+ with urllib.request.urlopen(request) as response:
233
+ return json.loads(response.read().decode())
234
+
235
+
236
+ def post_json(url: str, payload: dict, token: str = "") -> dict:
237
+ data = json.dumps(payload).encode()
238
+ headers = {"Content-Type": "application/json", CLIENT_HEADER: CLIENT_VALUE}
239
+ if token:
240
+ headers["Authorization"] = f"Bearer {token}"
241
+ request = urllib.request.Request(url, data=data, headers=headers)
242
+ with urllib.request.urlopen(request) as response:
243
+ return json.loads(response.read().decode())