hidevs-eval 0.1.1__tar.gz → 0.1.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hidevs-eval
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: HiDevs quest eval client. Runs the local agent and uploads replies for scoring.
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: pyyaml>=6
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hidevs-eval"
7
- version = "0.1.1"
7
+ version = "0.1.2"
8
8
  description = "HiDevs quest eval client. Runs the local agent and uploads replies for scoring."
9
9
  requires-python = ">=3.11"
10
10
  dependencies = [
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hidevs-eval
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: HiDevs quest eval client. Runs the local agent and uploads replies for scoring.
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: pyyaml>=6
@@ -8,4 +8,5 @@ src/hidevs_eval.egg-info/requires.txt
8
8
  src/hidevs_eval.egg-info/top_level.txt
9
9
  src/hidevs_quest/__init__.py
10
10
  src/hidevs_quest/cli.py
11
+ src/hidevs_quest/graphify.py
11
12
  src/hidevs_quest/runner.py
@@ -0,0 +1 @@
1
+ __version__ = "0.1.2"
@@ -273,10 +273,10 @@ def evaluate(args: argparse.Namespace) -> None:
273
273
  if started.get("result"):
274
274
  reports.append(started["result"])
275
275
  continue
276
- results = run_cases(config, root, started["cases"])
276
+ results, ast_graph = run_cases(config, root, started["cases"])
277
277
  reports.append(api(
278
278
  f"{session['server']}/v1/runs/{started['run_id']}",
279
- {"results": results},
279
+ {"results": results, "ast_graph": ast_graph},
280
280
  session["token"],
281
281
  ))
282
282
  print_result(reports)
@@ -0,0 +1,782 @@
1
+ """Graphify: anti-gaming AST call/dataflow graph + runtime execution/argument tracing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ # ==============================================================================
13
+ # Strategy A — Static AST Call Graph + Argument / Dataflow Integrity
14
+ # ==============================================================================
15
+
16
+
17
+ def _call_name(node: ast.AST) -> str | None:
18
+ if isinstance(node, ast.Name):
19
+ return node.id
20
+ if isinstance(node, ast.Attribute):
21
+ return node.attr
22
+ return None
23
+
24
+
25
+ def _is_literal(node: ast.AST) -> bool:
26
+ if isinstance(node, ast.Constant):
27
+ return True
28
+ if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
29
+ return all(_is_literal(elt) for elt in node.elts)
30
+ if isinstance(node, ast.Dict):
31
+ return all(
32
+ (k is None or _is_literal(k)) and _is_literal(v)
33
+ for k, v in zip(node.keys, node.values)
34
+ )
35
+ if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)):
36
+ return _is_literal(node.operand)
37
+ return False
38
+
39
+
40
+ def _names_used(node: ast.AST) -> set[str]:
41
+ return {n.id for n in ast.walk(node) if isinstance(n, ast.Name)}
42
+
43
+
44
+ class ASTGraphVisitor(ast.NodeVisitor):
45
+ def __init__(self, file_path: str):
46
+ self.file_path = file_path
47
+ self.current_function: str | None = None
48
+ self.functions: dict[str, dict[str, Any]] = {}
49
+ self.calls: list[dict[str, Any]] = []
50
+ self.tool_call_sites: list[dict[str, Any]] = []
51
+ self.hardcoded_trajectory_sites: list[dict[str, Any]] = []
52
+
53
+ def visit_FunctionDef(self, node: ast.FunctionDef):
54
+ old = self.current_function
55
+ self.current_function = f"{self.file_path}:{node.name}"
56
+ params = [a.arg for a in node.args.args if a.arg not in {"self", "cls"}]
57
+ self.functions[self.current_function] = {
58
+ "name": node.name,
59
+ "params": params,
60
+ "file": self.file_path,
61
+ "lineno": node.lineno,
62
+ "calls": [],
63
+ }
64
+
65
+ for child in ast.walk(node):
66
+ if isinstance(child, ast.Return) and isinstance(child.value, ast.Dict):
67
+ self._inspect_return_dict(child.value, node.name)
68
+
69
+ self.generic_visit(node)
70
+ self.current_function = old
71
+
72
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
73
+ self.visit_FunctionDef(node) # type: ignore[arg-type]
74
+
75
+ def _inspect_return_dict(self, value: ast.Dict, fn_name: str) -> None:
76
+ for key, val in zip(value.keys, value.values):
77
+ key_name = key.value if isinstance(key, ast.Constant) else None
78
+ if key_name != "trajectory":
79
+ continue
80
+ if isinstance(val, ast.List) and val.elts and all(
81
+ isinstance(elt, ast.Constant) and isinstance(elt.value, str) for elt in val.elts
82
+ ):
83
+ self.hardcoded_trajectory_sites.append({
84
+ "function": fn_name,
85
+ "file": self.file_path,
86
+ "lineno": getattr(val, "lineno", 0),
87
+ "values": [elt.value for elt in val.elts], # type: ignore[union-attr]
88
+ })
89
+
90
+ def visit_Call(self, node: ast.Call):
91
+ if not self.current_function:
92
+ self.generic_visit(node)
93
+ return
94
+
95
+ func_name = _call_name(node.func)
96
+ if not func_name:
97
+ self.generic_visit(node)
98
+ return
99
+
100
+ arg_nodes = list(node.args) + [kw.value for kw in node.keywords]
101
+ literal_args = sum(1 for a in arg_nodes if _is_literal(a))
102
+ dynamic_names = set()
103
+ for a in arg_nodes:
104
+ if not _is_literal(a):
105
+ dynamic_names |= _names_used(a)
106
+
107
+ site = {
108
+ "caller": self.current_function,
109
+ "callee_short": func_name,
110
+ "lineno": getattr(node, "lineno", 0),
111
+ "arg_count": len(arg_nodes),
112
+ "literal_arg_count": literal_args,
113
+ "all_args_literal": bool(arg_nodes) and literal_args == len(arg_nodes),
114
+ "dynamic_names": sorted(dynamic_names),
115
+ "literal_values": [
116
+ getattr(a, "value", None) for a in arg_nodes if isinstance(a, ast.Constant)
117
+ ],
118
+ }
119
+ self.calls.append(site)
120
+ self.tool_call_sites.append(site)
121
+ self.functions[self.current_function]["calls"].append(func_name)
122
+ self.generic_visit(node)
123
+
124
+
125
+ def build_ast_graph(
126
+ sources: dict[str, str],
127
+ entry_spec: str = "",
128
+ tools: list[dict] | None = None,
129
+ ) -> dict[str, Any]:
130
+ """Build AST call graph and flag hardcoded tool args / fake trajectories."""
131
+ all_functions: dict[str, dict[str, Any]] = {}
132
+ all_calls: list[dict[str, Any]] = []
133
+ all_sites: list[dict[str, Any]] = []
134
+ hardcoded_trajectories: list[dict[str, Any]] = []
135
+
136
+ for path, code in sources.items():
137
+ try:
138
+ tree = ast.parse(code)
139
+ except SyntaxError:
140
+ continue
141
+ visitor = ASTGraphVisitor(path)
142
+ visitor.visit(tree)
143
+ all_functions.update(visitor.functions)
144
+ all_calls.extend(visitor.calls)
145
+ all_sites.extend(visitor.tool_call_sites)
146
+ hardcoded_trajectories.extend(visitor.hardcoded_trajectory_sites)
147
+
148
+ nodes = list(all_functions.keys())
149
+ edges = []
150
+ for call in all_calls:
151
+ caller = call["caller"]
152
+ callee_short = call["callee_short"]
153
+ matched = [fn for fn in nodes if fn.endswith(f":{callee_short}")]
154
+ if matched:
155
+ for target in matched:
156
+ edges.append({"source": caller, "target": target, "type": "call"})
157
+ else:
158
+ edges.append({"source": caller, "target": callee_short, "type": "external_call"})
159
+
160
+ entry_node = None
161
+ entry_params: list[str] = []
162
+ if entry_spec:
163
+ parts = entry_spec.split(":")
164
+ method = None
165
+ # Prefer explicit method from config if present in entry_spec class form:
166
+ # "module:Class" — method resolved separately by caller via tools + "run" fallback.
167
+ fn_candidates = [parts[-1], "run", "execute", "call", "handle"]
168
+ for candidate in fn_candidates:
169
+ for fn_key in nodes:
170
+ if fn_key.endswith(f":{candidate}"):
171
+ entry_node = fn_key
172
+ entry_params = list(all_functions[fn_key].get("params") or [])
173
+ break
174
+ if entry_node:
175
+ break
176
+
177
+ reachable: set[str] = set()
178
+ if entry_node:
179
+ queue = [entry_node]
180
+ reachable.add(entry_node)
181
+ while queue:
182
+ curr = queue.pop(0)
183
+ for edge in edges:
184
+ tgt = edge["target"]
185
+ if edge["source"] == curr and tgt not in reachable:
186
+ reachable.add(tgt)
187
+ queue.append(tgt)
188
+
189
+ tool_functions: dict[str, str] = {}
190
+ for tool in tools or []:
191
+ spec = tool.get("function") or ""
192
+ cap = tool.get("capability") or spec
193
+ if ":" in spec:
194
+ tool_functions[cap] = spec.split(":", 1)[1]
195
+
196
+ referenced_names: set[str] = set()
197
+ for _path, code in sources.items():
198
+ try:
199
+ tree = ast.parse(code)
200
+ except SyntaxError:
201
+ continue
202
+ for node in ast.walk(tree):
203
+ if isinstance(node, ast.Name) and isinstance(getattr(node, "ctx", None), ast.Load):
204
+ referenced_names.add(node.id)
205
+
206
+ tool_reachability: dict[str, Any] = {}
207
+ hardcoded_tool_args: list[dict[str, Any]] = []
208
+ input_disconnected_tools: list[dict[str, Any]] = []
209
+
210
+ for cap, fn_name in tool_functions.items():
211
+ matched_tools = [n for n in nodes if n.endswith(f":{fn_name}")]
212
+ is_reachable = any(n in reachable for n in matched_tools)
213
+ is_bound = fn_name in referenced_names
214
+ # LLM agents often dispatch via a tool registry dict; a Load binding counts.
215
+ tool_reachability[cap] = {
216
+ "function": fn_name,
217
+ "exists_in_ast": bool(matched_tools),
218
+ "reachable_from_entry": is_reachable or (bool(matched_tools) and is_bound),
219
+ "call_graph_reachable": is_reachable,
220
+ "name_bound_in_source": is_bound,
221
+ }
222
+
223
+ # Call sites that invoke this tool from entry-reachable callers
224
+ sites = [
225
+ s for s in all_sites
226
+ if s["callee_short"] == fn_name and s["caller"] in reachable
227
+ ]
228
+ for site in sites:
229
+ if site["arg_count"] > 0 and site["all_args_literal"]:
230
+ hardcoded_tool_args.append({
231
+ "capability": cap,
232
+ "function": fn_name,
233
+ "caller": site["caller"],
234
+ "lineno": site["lineno"],
235
+ "literal_values": site["literal_values"],
236
+ })
237
+ # Numeric/string tool args must touch entry params or derived names when entry has params
238
+ elif entry_params and site["arg_count"] > 0:
239
+ dyn = set(site.get("dynamic_names") or [])
240
+ # Allow derived locals (any non-literal name). Fail only if NO dynamic names
241
+ # and some literals mixed in (partial hardcode).
242
+ if site["literal_arg_count"] > 0 and not dyn:
243
+ hardcoded_tool_args.append({
244
+ "capability": cap,
245
+ "function": fn_name,
246
+ "caller": site["caller"],
247
+ "lineno": site["lineno"],
248
+ "literal_values": site["literal_values"],
249
+ "reason": "mixed_or_literal_without_dynamic_names",
250
+ })
251
+ # Input disconnection: dynamic names exist but none relate to entry params
252
+ # and none look like cleaned/parsed derivatives of the input param names.
253
+ if dyn and entry_params:
254
+ related = dyn & set(entry_params)
255
+ # Common derivative names still count if they come from assignment
256
+ # of entry params — tracked lightly via shared tokens.
257
+ if not related:
258
+ # Soft signal: if literals dominate, already caught; if only
259
+ # unrelated names AND there are numeric literals, flag.
260
+ if site["literal_arg_count"] > 0:
261
+ input_disconnected_tools.append({
262
+ "capability": cap,
263
+ "function": fn_name,
264
+ "caller": site["caller"],
265
+ "lineno": site["lineno"],
266
+ "dynamic_names": sorted(dyn),
267
+ "entry_params": entry_params,
268
+ "literal_values": site["literal_values"],
269
+ })
270
+
271
+ return {
272
+ "nodes": nodes,
273
+ "edges": edges,
274
+ "entry_node": entry_node,
275
+ "entry_params": entry_params,
276
+ "reachable_nodes": list(reachable),
277
+ "tool_reachability": tool_reachability,
278
+ "hardcoded_tool_args": hardcoded_tool_args,
279
+ "input_disconnected_tools": input_disconnected_tools,
280
+ "hardcoded_trajectory_sites": hardcoded_trajectories,
281
+ "has_hardcoded_tool_args": bool(hardcoded_tool_args),
282
+ "has_hardcoded_trajectory": bool(hardcoded_trajectories),
283
+ "has_hardcoded_literals": bool(hardcoded_tool_args) or bool(hardcoded_trajectories),
284
+ }
285
+
286
+
287
+ # ==============================================================================
288
+ # Strategy B — Runtime Execution Trace + Argument Capture
289
+ # ==============================================================================
290
+
291
+ LLM_PATH_MARKERS = (
292
+ "google/genai",
293
+ "google\\genai",
294
+ "google/generativeai",
295
+ "google/ai/generativelanguage",
296
+ "langchain_google_genai",
297
+ "langchain_openai",
298
+ "langchain_anthropic",
299
+ "langchain_community/chat_models",
300
+ "langchain_community\\chat_models",
301
+ "/openai/",
302
+ "\\openai\\",
303
+ "/anthropic/",
304
+ "\\anthropic\\",
305
+ "litellm",
306
+ "/lyzr",
307
+ "\\lyzr",
308
+ )
309
+
310
+ LLM_METHOD_NAMES = {
311
+ "generate_content",
312
+ "generate_content_stream",
313
+ "create",
314
+ "complete",
315
+ "acomplete",
316
+ "invoke",
317
+ "ainvoke",
318
+ "run_agent",
319
+ "_generate",
320
+ "_agenerate",
321
+ "generate",
322
+ }
323
+
324
+
325
+ def _extract_model_from_frame(frame) -> str | None:
326
+ locals_map = frame.f_locals
327
+ for key in ("model", "model_name", "engine", "model_id"):
328
+ value = locals_map.get(key)
329
+ if isinstance(value, str) and value:
330
+ return value
331
+ kwargs = locals_map.get("kwargs")
332
+ if isinstance(kwargs, dict):
333
+ for key in ("model", "model_name", "engine", "model_id"):
334
+ value = kwargs.get(key)
335
+ if isinstance(value, str) and value:
336
+ return value
337
+ self_obj = locals_map.get("self")
338
+ if self_obj is not None:
339
+ for key in ("model", "model_name", "model_id", "engine"):
340
+ value = getattr(self_obj, key, None)
341
+ if isinstance(value, str) and value:
342
+ return value
343
+ return None
344
+
345
+
346
+ def _is_llm_sdk_file(filename: str) -> bool:
347
+ if not filename or filename.startswith("<"):
348
+ return False
349
+ lowered = filename.replace("\\", "/").lower()
350
+ return any(marker in lowered for marker in LLM_PATH_MARKERS)
351
+
352
+
353
+ class ExecutionTracer:
354
+ """Record user-code call DAG + LLM SDK invokes via sys.settrace."""
355
+
356
+ _active = None
357
+
358
+ def __init__(self, root_dir: Path):
359
+ self.root_dir = root_dir.resolve()
360
+ self.call_stack: list[dict[str, Any]] = []
361
+ self.recorded_events: list[dict[str, Any]] = []
362
+ self.nodes: set[str] = set()
363
+ self.edges: list[dict[str, str]] = []
364
+ self.llm_calls: list[dict[str, Any]] = []
365
+ self.start_time = 0.0
366
+ self.old_trace = None
367
+ self._previous_active = None
368
+
369
+ def _is_user_file(self, filename: str) -> bool:
370
+ if not filename or filename.startswith("<") or "importlib" in filename:
371
+ return False
372
+ try:
373
+ p = Path(filename).resolve()
374
+ return p.is_relative_to(self.root_dir) and not any(
375
+ part in {".venv", "venv", "__pycache__", ".hidevs", ".eval_artifacts"}
376
+ for part in p.parts
377
+ )
378
+ except (ValueError, OSError):
379
+ return False
380
+
381
+ def trace_calls(self, frame, event, arg):
382
+ if event not in ("call", "return"):
383
+ return self.trace_calls
384
+ code = frame.f_code
385
+ filename = code.co_filename
386
+ fn_name = code.co_name
387
+
388
+ # Track provider SDK LLM methods even outside user project root
389
+ if event == "call" and _is_llm_sdk_file(filename):
390
+ path_l = filename.replace("\\", "/").lower()
391
+ is_llm_method = fn_name in LLM_METHOD_NAMES or (
392
+ fn_name == "create"
393
+ and any(tok in path_l for tok in ("chat/completions", "/messages", "completions"))
394
+ )
395
+ if is_llm_method:
396
+ model = _extract_model_from_frame(frame)
397
+ self.llm_calls.append({
398
+ "method": fn_name,
399
+ "file": filename,
400
+ "model": model,
401
+ "t": time.monotonic() - self.start_time,
402
+ })
403
+ node_id = f"llm:{fn_name}"
404
+ self.nodes.add(node_id)
405
+ if self.call_stack:
406
+ parent = self.call_stack[-1]["node_id"]
407
+ edge = {"source": parent, "target": node_id}
408
+ if edge not in self.edges:
409
+ self.edges.append(edge)
410
+
411
+ if not self._is_user_file(filename):
412
+ return self.trace_calls
413
+
414
+ rel = Path(filename).resolve().relative_to(self.root_dir).as_posix()
415
+ node_id = f"{rel}:{fn_name}"
416
+
417
+ if event == "call":
418
+ self.nodes.add(node_id)
419
+ if self.call_stack:
420
+ parent = self.call_stack[-1]["node_id"]
421
+ edge = {"source": parent, "target": node_id}
422
+ if edge not in self.edges:
423
+ self.edges.append(edge)
424
+ self.call_stack.append({"node_id": node_id})
425
+ self.recorded_events.append({"type": "call", "node_id": node_id})
426
+ # Do NOT count in-project method names as LLM SDK calls.
427
+ # Real provider calls are captured from SDK file paths above.
428
+ elif event == "return" and self.call_stack:
429
+ if self.call_stack[-1]["node_id"] == node_id:
430
+ self.call_stack.pop()
431
+ self.recorded_events.append({"type": "return", "node_id": node_id})
432
+ return self.trace_calls
433
+
434
+ def note_tool(self, node_id: str) -> None:
435
+ """Record a declared tool the harness wrapper actually invoked."""
436
+ node_id = str(node_id or "")
437
+ if ":" not in node_id:
438
+ return
439
+ self.nodes.add(node_id)
440
+ self.recorded_events.append({"type": "call", "node_id": node_id})
441
+
442
+ def __enter__(self):
443
+ self.start_time = time.monotonic()
444
+ self.old_trace = sys.gettrace()
445
+ self._previous_active = ExecutionTracer._active
446
+ ExecutionTracer._active = self
447
+ sys.settrace(self.trace_calls)
448
+ return self
449
+
450
+ def __exit__(self, exc_type, exc_val, exc_tb):
451
+ sys.settrace(self.old_trace)
452
+ ExecutionTracer._active = self._previous_active
453
+
454
+ def get_graph(self) -> dict[str, Any]:
455
+ return {
456
+ "nodes": list(self.nodes),
457
+ "edges": self.edges,
458
+ "call_sequence": [e["node_id"] for e in self.recorded_events if e["type"] == "call"],
459
+ "total_calls": len(self.edges),
460
+ "llm_calls": list(self.llm_calls),
461
+ }
462
+
463
+
464
+ def _jsonable(value: Any) -> Any:
465
+ if isinstance(value, (str, int, float, bool)) or value is None:
466
+ return value
467
+ if isinstance(value, (list, tuple)):
468
+ return [_jsonable(v) for v in value[:20]]
469
+ if isinstance(value, dict):
470
+ return {str(k): _jsonable(v) for k, v in list(value.items())[:20]}
471
+ return repr(value)[:200]
472
+
473
+
474
+ def summarize_tool_args(tool_calls: dict[str, list[dict]]) -> dict[str, Any]:
475
+ """Detect static / input-independent tool arguments across cases."""
476
+ static_caps: list[str] = []
477
+ details: dict[str, Any] = {}
478
+ for cap, rows in tool_calls.items():
479
+ if len(rows) < 2:
480
+ details[cap] = {"cases": len(rows), "static_across_cases": False}
481
+ continue
482
+ signatures = [row.get("signature") for row in rows]
483
+ static = len(set(signatures)) == 1 and all(signatures)
484
+ if static:
485
+ static_caps.append(cap)
486
+ details[cap] = {
487
+ "cases": len(rows),
488
+ "static_across_cases": static,
489
+ "signatures": signatures[:5],
490
+ }
491
+ return {"static_capabilities": static_caps, "by_capability": details}
492
+
493
+
494
+ # ==============================================================================
495
+ # Graph Matcher — fail closed on gaming patterns
496
+ # ==============================================================================
497
+
498
+
499
+ def verify_graphify(
500
+ ast_graph: dict,
501
+ runtime_graph: dict,
502
+ required_tools: list[str],
503
+ tools_config: list[dict] | None = None,
504
+ case_inputs: list[dict] | None = None,
505
+ ) -> dict[str, Any]:
506
+ """Fail closed: tools must be reachable, executed, input-driven, non-hardcoded."""
507
+ reasons: list[str] = []
508
+ checks_passed = True
509
+
510
+ tool_fn_map: dict[str, str] = {}
511
+ for t in tools_config or []:
512
+ cap = t.get("capability") or t.get("function")
513
+ func_spec = t.get("function") or ""
514
+ if ":" in func_spec:
515
+ tool_fn_map[cap] = func_spec.split(":", 1)[1]
516
+ elif func_spec:
517
+ tool_fn_map[cap] = func_spec
518
+
519
+ # 1) AST reachability
520
+ for cap in required_tools:
521
+ reach = (ast_graph.get("tool_reachability") or {}).get(cap) or {}
522
+ if not reach.get("reachable_from_entry"):
523
+ reasons.append(f"AST: required tool not reachable from entry: {cap}")
524
+ checks_passed = False
525
+
526
+ # 2) Hardcoded tool arguments in source
527
+ hardcoded = ast_graph.get("hardcoded_tool_args") or []
528
+ if hardcoded:
529
+ sample = hardcoded[0]
530
+ reasons.append(
531
+ "AST: tool called with hardcoded literal arguments "
532
+ f"({sample.get('capability')}: {sample.get('literal_values')} at line {sample.get('lineno')})"
533
+ )
534
+ checks_passed = False
535
+
536
+ # 3) Input-disconnected numeric tool calls
537
+ disconnected = ast_graph.get("input_disconnected_tools") or []
538
+ if disconnected:
539
+ sample = disconnected[0]
540
+ reasons.append(
541
+ "AST: tool args include literals not derived from entry input "
542
+ f"({sample.get('capability')}: literals={sample.get('literal_values')})"
543
+ )
544
+ checks_passed = False
545
+
546
+ # 4) Fake hardcoded trajectory list in return
547
+ if ast_graph.get("hardcoded_trajectory_sites") or ast_graph.get("has_hardcoded_trajectory"):
548
+ sites = ast_graph.get("hardcoded_trajectory_sites") or []
549
+ values = sites[0].get("values") if sites else []
550
+ reasons.append(
551
+ f"AST: hardcoded trajectory list detected in return (self-reported fake tool trace): {values}"
552
+ )
553
+ checks_passed = False
554
+
555
+ # 5) Runtime execution of required tools (graph nodes or harness counts)
556
+ executed_nodes = [str(node) for node in (runtime_graph.get("nodes") or [])]
557
+ counts = runtime_graph.get("counts") if isinstance(runtime_graph.get("counts"), dict) else {}
558
+ missing_execution: list[str] = []
559
+ for cap in required_tools:
560
+ fn_name = tool_fn_map.get(cap, cap.split(".")[-1])
561
+ in_nodes = any(node.endswith(f":{fn_name}") for node in executed_nodes)
562
+ try:
563
+ in_counts = int(counts.get(cap) or 0) > 0
564
+ except (TypeError, ValueError):
565
+ in_counts = False
566
+ if not in_nodes and not in_counts:
567
+ reasons.append(f"Runtime: required tool was not executed: {cap}")
568
+ checks_passed = False
569
+ missing_execution.append(cap)
570
+
571
+ # 6) Runtime tool args static across differing inputs
572
+ arg_summary = runtime_graph.get("tool_arg_summary") or {}
573
+ static_caps = arg_summary.get("static_capabilities") or []
574
+ if static_caps and case_inputs and len(case_inputs) >= 2:
575
+ # Only fail when case inputs actually differ
576
+ input_sigs = [
577
+ repr(sorted((c.get("input") or c).items())) if isinstance(c, dict) else repr(c)
578
+ for c in case_inputs
579
+ ]
580
+ if len(set(input_sigs)) > 1:
581
+ reasons.append(
582
+ "Runtime: tool arguments identical across different case inputs "
583
+ f"(static gaming): {static_caps}"
584
+ )
585
+ checks_passed = False
586
+
587
+ # 7) Empty graph only when the harness also recorded no calls
588
+ if required_tools and not executed_nodes and missing_execution:
589
+ reasons.append("Runtime: empty execution graph")
590
+ checks_passed = False
591
+
592
+ return {
593
+ "passed": checks_passed,
594
+ "score": 1.0 if checks_passed else 0.0,
595
+ "reasons": reasons,
596
+ "ast_nodes_count": len(ast_graph.get("nodes") or []),
597
+ "runtime_nodes_count": len(executed_nodes),
598
+ "executed_tool_nodes": executed_nodes,
599
+ "hardcoded_tool_args": hardcoded,
600
+ "hardcoded_trajectory_sites": ast_graph.get("hardcoded_trajectory_sites") or [],
601
+ "tool_arg_summary": arg_summary,
602
+ }
603
+
604
+
605
+ # ==============================================================================
606
+ # L1.12 — LLM Integrity (real provider SDK call, not rule-only)
607
+ # ==============================================================================
608
+
609
+ LLM_PROVIDER_IMPORTS = {
610
+ "google": ("google.genai", "google.generativeai", "genai", "langchain_google_genai"),
611
+ "openai": ("openai", "langchain_openai"),
612
+ "anthropic": ("anthropic", "langchain_anthropic"),
613
+ "lyzr": ("lyzr",),
614
+ "litellm": ("litellm",),
615
+ "other": (),
616
+ }
617
+
618
+ LLM_CALL_MARKERS = (
619
+ "generate_content",
620
+ "generate_content_stream",
621
+ "chat",
622
+ "completions",
623
+ "messages",
624
+ "complete",
625
+ "acomplete",
626
+ "invoke",
627
+ "ainvoke",
628
+ "run_agent",
629
+ )
630
+
631
+
632
+ def analyze_llm_ast(sources: dict[str, str], llm_claim: dict | None) -> dict[str, Any]:
633
+ """Static check: declared LLM import/call present; model string referenced."""
634
+ claim = llm_claim or {}
635
+ provider = claim.get("provider") or ""
636
+ model = claim.get("model") or ""
637
+ import_name = claim.get("import") or ""
638
+ call_path = claim.get("call") or ""
639
+ call_tail = call_path.split(".")[-1] if call_path else ""
640
+
641
+ import_found = False
642
+ call_found = False
643
+ model_literal_found = False
644
+ provider_sdk_seen = False
645
+ evidence: list[str] = []
646
+
647
+ for path, text in sources.items():
648
+ if import_name and (f"import {import_name}" in text or f"from {import_name}" in text or f"from {import_name.split('.')[0]}" in text):
649
+ import_found = True
650
+ evidence.append(f"import:{path}")
651
+ for marker in LLM_PROVIDER_IMPORTS.get(provider, ()):
652
+ if marker and (f"import {marker}" in text or f"from {marker}" in text):
653
+ provider_sdk_seen = True
654
+ if call_path and call_path in text:
655
+ call_found = True
656
+ evidence.append(f"call_path:{path}")
657
+ if call_tail and call_tail in text:
658
+ # weaker signal — method name appears
659
+ if any(token in text for token in (f".{call_tail}(", f"{call_tail}(")):
660
+ call_found = True
661
+ evidence.append(f"call_tail:{path}:{call_tail}")
662
+ if model and (f'"{model}"' in text or f"'{model}'" in text):
663
+ model_literal_found = True
664
+ evidence.append(f"model_literal:{path}")
665
+ # generic LLM call markers in source
666
+ for marker in LLM_CALL_MARKERS:
667
+ if f".{marker}(" in text or f"{marker}(" in text:
668
+ if provider in {"google", "openai", "anthropic", "lyzr", "litellm", "other"}:
669
+ evidence.append(f"marker:{path}:{marker}")
670
+
671
+ return {
672
+ "provider": provider,
673
+ "model": model,
674
+ "import_found": import_found or provider_sdk_seen,
675
+ "call_found": call_found,
676
+ "model_literal_found": model_literal_found,
677
+ "provider_sdk_seen": provider_sdk_seen,
678
+ "evidence": evidence[:20],
679
+ }
680
+
681
+
682
+ def verify_llm_integrity(
683
+ sources: dict[str, str],
684
+ config: dict,
685
+ runtime_graph: dict,
686
+ suite: dict | None = None,
687
+ ) -> dict[str, Any]:
688
+ """Fail if no real LLM declaration / AST usage / runtime LLM invoke."""
689
+ reasons: list[str] = []
690
+ checks_passed = True
691
+ suite = suite or {}
692
+ require = suite.get("required_llm", True)
693
+
694
+ llm_claim = ((config.get("requirements") or {}).get("llm")) or {}
695
+ if require and not llm_claim:
696
+ reasons.append("requirements.llm is missing (provider, model, import, call required)")
697
+ checks_passed = False
698
+ return {
699
+ "passed": False,
700
+ "score": 0.0,
701
+ "reasons": reasons,
702
+ "ast": {},
703
+ "runtime_llm_calls": [],
704
+ }
705
+
706
+ ast_info = analyze_llm_ast(sources, llm_claim)
707
+ if require and not ast_info["import_found"]:
708
+ reasons.append(
709
+ f"AST: declared LLM import not found for provider={ast_info['provider']} import={llm_claim.get('import')}"
710
+ )
711
+ checks_passed = False
712
+ if require and not ast_info["call_found"]:
713
+ reasons.append(
714
+ f"AST: declared LLM call not found in sources: {llm_claim.get('call')}"
715
+ )
716
+ checks_passed = False
717
+
718
+ runtime_calls = list(runtime_graph.get("llm_calls") or [])
719
+ provider = (llm_claim.get("provider") or "").strip()
720
+ provider_markers = {
721
+ "google": (
722
+ "google/genai",
723
+ "google\\genai",
724
+ "google/generativeai",
725
+ "google/ai/generativelanguage",
726
+ "langchain_google_genai",
727
+ ),
728
+ "openai": ("/openai/", "\\openai\\", "openai/resources", "langchain_openai"),
729
+ "anthropic": ("/anthropic/", "\\anthropic\\", "langchain_anthropic"),
730
+ "lyzr": ("/lyzr", "\\lyzr"),
731
+ "litellm": ("litellm",),
732
+ "other": (),
733
+ }.get(provider, ())
734
+
735
+ def _matches_provider(call: dict) -> bool:
736
+ path = str(call.get("file") or "").replace("\\", "/").lower()
737
+ if not provider_markers:
738
+ return bool(path)
739
+ return any(marker.replace("\\", "/").lower() in path for marker in provider_markers)
740
+
741
+ provider_calls = [c for c in runtime_calls if _matches_provider(c)]
742
+ # Ignore no-op stubs: provider SDK call should come from installed package path
743
+ if require and not provider_calls:
744
+ reasons.append(
745
+ "Runtime: no real LLM provider SDK call observed for declared provider "
746
+ f"{provider!r} (rule-based / mock agents fail L1.12)"
747
+ )
748
+ checks_passed = False
749
+
750
+ declared_model = (llm_claim.get("model") or "").strip()
751
+ observed_models = sorted({
752
+ str(c.get("model")) for c in provider_calls if c.get("model")
753
+ })
754
+ if require and provider_calls and declared_model and observed_models:
755
+ if declared_model not in observed_models and not any(
756
+ declared_model in m or m in declared_model for m in observed_models
757
+ ):
758
+ reasons.append(
759
+ f"Runtime: declared model {declared_model!r} not observed in LLM calls "
760
+ f"(observed={observed_models})"
761
+ )
762
+ checks_passed = False
763
+ elif require and provider_calls and declared_model and not observed_models and provider in {"google", "openai", "anthropic"}:
764
+ reasons.append(
765
+ f"Runtime: LLM SDK was called but model id was not observed (declared={declared_model!r})"
766
+ )
767
+ checks_passed = False
768
+
769
+ # Instant local-only pattern: zero LLM calls already failed; also flag if claim says llm but only tools ran
770
+ return {
771
+ "passed": checks_passed,
772
+ "score": 1.0 if checks_passed else 0.0,
773
+ "reasons": reasons,
774
+ "ast": ast_info,
775
+ "runtime_llm_calls": runtime_calls[:20],
776
+ "declared": {
777
+ "provider": llm_claim.get("provider"),
778
+ "model": llm_claim.get("model"),
779
+ "import": llm_claim.get("import"),
780
+ "call": llm_claim.get("call"),
781
+ },
782
+ }
@@ -134,13 +134,33 @@ def invoke(config: dict, root: Path, case: dict) -> Any:
134
134
 
135
135
 
136
136
  class Scripted:
137
- def __init__(self, original, mode: str):
137
+ def __init__(self, original, mode: str, node_id: str = ""):
138
138
  self.original = original
139
139
  self.mode = mode
140
140
  self.calls = []
141
+ self.arg_records = []
142
+ self.node_id = node_id
143
+
144
+ def _note(self) -> None:
145
+ if not self.node_id:
146
+ return
147
+ from hidevs_quest.graphify import ExecutionTracer
148
+
149
+ tracer = getattr(ExecutionTracer, "_active", None)
150
+ if tracer is not None:
151
+ tracer.note_tool(self.node_id)
141
152
 
142
153
  def __call__(self, *args, **kwargs):
154
+ from hidevs_quest.graphify import _jsonable
155
+
156
+ record = {
157
+ "args": [_jsonable(a) for a in args],
158
+ "kwargs": {str(k): _jsonable(v) for k, v in kwargs.items()},
159
+ }
160
+ record["signature"] = repr((record["args"], sorted(record["kwargs"].items())))
143
161
  self.calls.append(True)
162
+ self.arg_records.append(record)
163
+ self._note()
144
164
  if self.mode == "raise":
145
165
  raise RuntimeError("injected tool failure")
146
166
  if self.mode == "timeout":
@@ -161,6 +181,17 @@ def modules_under(root: Path):
161
181
  continue
162
182
 
163
183
 
184
+ def _tool_node_id(root: Path, module, module_name: str, fn_name: str) -> str:
185
+ path = getattr(module, "__file__", None)
186
+ if path:
187
+ try:
188
+ rel = Path(path).resolve().relative_to(Path(root).resolve()).as_posix()
189
+ return f"{rel}:{fn_name}"
190
+ except (ValueError, OSError):
191
+ pass
192
+ return f"{module_name.replace('.', '/')}.py:{fn_name}"
193
+
194
+
164
195
  def patch_tools(root: Path, config: dict, mode: str):
165
196
  wrappers = {}
166
197
  restores = []
@@ -174,18 +205,18 @@ def patch_tools(root: Path, config: dict, mode: str):
174
205
  module_name, fn_name = spec.split(":", 1)
175
206
  module = __import__(module_name, fromlist=[fn_name])
176
207
  original = getattr(module, fn_name)
177
- wrapper = Scripted(original, mode)
208
+ wrapper = Scripted(original, mode, _tool_node_id(root, module, module_name, fn_name))
178
209
  wrappers[capability] = wrapper
179
210
  setattr(module, fn_name, wrapper)
180
211
  restores.append((module, fn_name, original))
181
212
  for loaded in modules_under(root):
182
213
  for attr, value in list(vars(loaded).items()):
183
- if value is original:
214
+ if value is original or (isinstance(value, Scripted) and value.original is original):
184
215
  setattr(loaded, attr, wrapper)
185
216
  restores.append((loaded, attr, original))
186
217
  if isinstance(value, dict):
187
218
  for key, item in list(value.items()):
188
- if item is original:
219
+ if item is original or (isinstance(item, Scripted) and item.original is original):
189
220
  value[key] = wrapper
190
221
  restores.append((value, key, original))
191
222
  return wrappers, restores
@@ -199,28 +230,80 @@ def restore(restores) -> None:
199
230
  setattr(owner, key, original)
200
231
 
201
232
 
202
- def run_cases(config: dict, root: Path, cases: list[dict]) -> list[dict]:
233
+ from hidevs_quest.graphify import ExecutionTracer, build_ast_graph, summarize_tool_args
234
+
235
+
236
+ def run_cases(config: dict, root: Path, cases: list[dict]) -> tuple[list[dict], dict]:
203
237
  results = []
238
+ all_runtime_graphs = {}
239
+ tool_calls_by_cap: dict[str, list[dict]] = {}
240
+
241
+ artifacts_dir = root / ".eval_artifacts"
242
+ artifacts_dir.mkdir(exist_ok=True)
243
+
244
+ sources = collect_sources(root)
245
+ entry = config.get("entry") or {}
246
+ entry_spec = entry.get("class") or entry.get("callable") or ""
247
+ # Prefer method name for entry node resolution (class:Method style via method field)
248
+ method = entry.get("method") or ""
249
+ if method and ":" in entry_spec:
250
+ # Keep class for import; pass method hint by appending candidate via tools build
251
+ pass
252
+ ast_graph = build_ast_graph(sources, entry_spec if not method else f"{entry_spec}:{method}", config.get("tools"))
253
+ # If method provided, force entry candidates to include method
254
+ if method and not (ast_graph.get("entry_node") or "").endswith(f":{method}"):
255
+ ast_graph = build_ast_graph(sources, method, config.get("tools"))
256
+ (artifacts_dir / "ast_graph.json").write_text(json.dumps(ast_graph, indent=2))
257
+
204
258
  for case in cases:
205
259
  mode = case.get("tool_mode") or "ok"
206
260
  wrappers, restores = patch_tools(root, config, mode)
261
+ runtime_graph = {}
207
262
  try:
208
263
  try:
209
- payload = as_dict(invoke(config, root, case))
264
+ with ExecutionTracer(root) as tracer:
265
+ payload = as_dict(invoke(config, root, case))
266
+ runtime_graph = tracer.get_graph()
210
267
  error = ""
211
268
  except Exception:
212
269
  payload = {}
213
270
  error = traceback.format_exc()
214
271
  finally:
215
272
  restore(restores)
273
+
274
+ tool_arg_calls = {}
275
+ for name, wrapper in wrappers.items():
276
+ tool_arg_calls[name] = list(wrapper.arg_records)
277
+ tool_calls_by_cap.setdefault(name, []).extend(
278
+ {**rec, "case_id": case["id"]} for rec in wrapper.arg_records
279
+ )
280
+ runtime_graph["tool_arg_calls"] = tool_arg_calls
281
+
216
282
  counts = {name: len(wrapper.calls) for name, wrapper in wrappers.items()}
283
+ runtime_graph = dict(runtime_graph or {})
284
+ runtime_graph["counts"] = counts
285
+ all_runtime_graphs[case["id"]] = runtime_graph
217
286
  results.append({
218
287
  "id": case["id"],
219
288
  "payload": payload,
220
289
  "error": error[-1500:] if error else "",
221
290
  "counts": counts,
291
+ "runtime_graph": runtime_graph,
222
292
  })
223
- return results
293
+
294
+ tool_arg_summary = summarize_tool_args(tool_calls_by_cap)
295
+ all_llm_calls = []
296
+ for graph in all_runtime_graphs.values():
297
+ graph["tool_arg_summary"] = tool_arg_summary
298
+ all_llm_calls.extend(graph.get("llm_calls") or [])
299
+ for row in results:
300
+ row["runtime_graph"]["tool_arg_summary"] = tool_arg_summary
301
+ row["runtime_graph"]["llm_calls"] = all_llm_calls
302
+
303
+ (artifacts_dir / "execution_trace_graph.json").write_text(json.dumps(all_runtime_graphs, indent=2))
304
+ (artifacts_dir / "tool_arg_summary.json").write_text(json.dumps(tool_arg_summary, indent=2))
305
+ (artifacts_dir / "llm_calls.json").write_text(json.dumps(all_llm_calls, indent=2))
306
+ return results, ast_graph
224
307
 
225
308
 
226
309
  CLIENT_HEADER = "X-Hidevs-Client"
@@ -1 +0,0 @@
1
- __version__ = "0.1.1"
File without changes
File without changes