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.
- {hidevs_eval-0.1.1 → hidevs_eval-0.1.2}/PKG-INFO +1 -1
- {hidevs_eval-0.1.1 → hidevs_eval-0.1.2}/pyproject.toml +1 -1
- {hidevs_eval-0.1.1 → hidevs_eval-0.1.2}/src/hidevs_eval.egg-info/PKG-INFO +1 -1
- {hidevs_eval-0.1.1 → hidevs_eval-0.1.2}/src/hidevs_eval.egg-info/SOURCES.txt +1 -0
- hidevs_eval-0.1.2/src/hidevs_quest/__init__.py +1 -0
- {hidevs_eval-0.1.1 → hidevs_eval-0.1.2}/src/hidevs_quest/cli.py +2 -2
- hidevs_eval-0.1.2/src/hidevs_quest/graphify.py +782 -0
- {hidevs_eval-0.1.1 → hidevs_eval-0.1.2}/src/hidevs_quest/runner.py +90 -7
- hidevs_eval-0.1.1/src/hidevs_quest/__init__.py +0 -1
- {hidevs_eval-0.1.1 → hidevs_eval-0.1.2}/README.md +0 -0
- {hidevs_eval-0.1.1 → hidevs_eval-0.1.2}/setup.cfg +0 -0
- {hidevs_eval-0.1.1 → hidevs_eval-0.1.2}/src/hidevs_eval.egg-info/dependency_links.txt +0 -0
- {hidevs_eval-0.1.1 → hidevs_eval-0.1.2}/src/hidevs_eval.egg-info/entry_points.txt +0 -0
- {hidevs_eval-0.1.1 → hidevs_eval-0.1.2}/src/hidevs_eval.egg-info/requires.txt +0 -0
- {hidevs_eval-0.1.1 → hidevs_eval-0.1.2}/src/hidevs_eval.egg-info/top_level.txt +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|