hidevs-eval 0.1.2__tar.gz → 0.1.3__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.2
3
+ Version: 0.1.3
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.2"
7
+ version = "0.1.3"
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.2
3
+ Version: 0.1.3
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
@@ -9,4 +9,5 @@ src/hidevs_eval.egg-info/top_level.txt
9
9
  src/hidevs_quest/__init__.py
10
10
  src/hidevs_quest/cli.py
11
11
  src/hidevs_quest/graphify.py
12
+ src/hidevs_quest/llm_probe.py
12
13
  src/hidevs_quest/runner.py
@@ -0,0 +1 @@
1
+ __version__ = "0.1.3"
@@ -398,6 +398,8 @@ class ExecutionTracer:
398
398
  "method": fn_name,
399
399
  "file": filename,
400
400
  "model": model,
401
+ "via": "sdk",
402
+ "proven": True,
401
403
  "t": time.monotonic() - self.start_time,
402
404
  })
403
405
  node_id = f"llm:{fn_name}"
@@ -0,0 +1,436 @@
1
+ """Prove a declared model call actually hit a model.
2
+
3
+ A config entry such as ``meetagent.provider.chat_completion`` is wrapped at eval
4
+ time. The wrapper counts only when, inside that call, an HTTP request looks like
5
+ a chat/completions call and the value returned to the agent was taken from the
6
+ response. A function that returns a fixed string, ignores the response, or echoes
7
+ the prompt is not recorded.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ast
13
+ import hashlib
14
+ import importlib
15
+ import inspect
16
+ import json
17
+ import re
18
+ import sys
19
+ import urllib.parse
20
+ import urllib.request
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ SECRET_TEXT = re.compile(
25
+ r"mongodb(?:\+srv)?://\S+|AIza[0-9A-Za-z_\-]{20,}|sk-[0-9A-Za-z]{16,}|\b(?:\d[ -]?){13,16}\b",
26
+ re.IGNORECASE,
27
+ )
28
+
29
+ _LLM_HINTS = (
30
+ "chat/completions",
31
+ "/completions",
32
+ "generatecontent",
33
+ "/api/chat",
34
+ "/api/generate",
35
+ "/v1/messages",
36
+ "/v1/responses",
37
+ "generativelanguage",
38
+ )
39
+ _BODY_KEYS = {"messages", "contents", "prompt", "input"}
40
+ _HOOKS: list[tuple[Any, str, Any]] = []
41
+
42
+
43
+ def redact_text(value: str) -> str:
44
+ return SECRET_TEXT.sub("[redacted]", value)
45
+
46
+
47
+ def clip_value(value: Any, depth: int = 0) -> Any:
48
+ if isinstance(value, str):
49
+ return redact_text(value)[:800]
50
+ if isinstance(value, (int, float, bool)) or value is None:
51
+ return value
52
+ if depth >= 4:
53
+ return redact_text(repr(value))[:200]
54
+ if isinstance(value, dict):
55
+ return {str(k)[:80]: clip_value(v, depth + 1) for k, v in list(value.items())[:12]}
56
+ if isinstance(value, (list, tuple)):
57
+ return [clip_value(v, depth + 1) for v in list(value)[:8]]
58
+ return redact_text(repr(value))[:200]
59
+
60
+
61
+ def resolve_llm_callable(root: Path, call: str):
62
+ """Return (module, attr, fn) for ``pkg.mod.fn`` or ``pkg.mod:fn``. Otherwise None."""
63
+ call = (call or "").strip()
64
+ if not call or call in {"invoke", "llm.invoke"} or call.endswith(".invoke"):
65
+ # Instance methods such as llm.invoke are observed through the SDK tracer.
66
+ if ":" not in call:
67
+ return None
68
+ root_s = str(root)
69
+ if root_s not in sys.path:
70
+ sys.path.insert(0, root_s)
71
+ if ":" in call:
72
+ module_name, attr = call.split(":", 1)
73
+ if not module_name or "." in attr or not attr.isidentifier():
74
+ return None
75
+ try:
76
+ module = importlib.import_module(module_name)
77
+ except Exception:
78
+ return None
79
+ fn = getattr(module, attr, None)
80
+ if callable(fn):
81
+ return module, attr, fn
82
+ return None
83
+ parts = call.split(".")
84
+ if len(parts) < 2:
85
+ return None
86
+ attr = parts[-1]
87
+ module_name = ".".join(parts[:-1])
88
+ if not attr.isidentifier() or not module_name:
89
+ return None
90
+ try:
91
+ module = importlib.import_module(module_name)
92
+ except Exception:
93
+ return None
94
+ fn = getattr(module, attr, None)
95
+ if callable(fn):
96
+ return module, attr, fn
97
+ return None
98
+
99
+
100
+ def _fingerprint(text: str) -> str:
101
+ normalized = " ".join((text or "").split())[:2000]
102
+ return hashlib.sha256(normalized.encode("utf-8", errors="ignore")).hexdigest()[:16]
103
+
104
+
105
+ def _result_text(result: Any) -> str:
106
+ if isinstance(result, str):
107
+ return result.strip()
108
+ if isinstance(result, dict):
109
+ for key in ("content", "text", "output", "answer"):
110
+ value = result.get(key)
111
+ if isinstance(value, str) and value.strip():
112
+ return value.strip()
113
+ content = getattr(result, "content", None)
114
+ if isinstance(content, str):
115
+ return content.strip()
116
+ return ""
117
+
118
+
119
+ def _return_constants(source: str) -> set[str]:
120
+ try:
121
+ tree = ast.parse(source)
122
+ except SyntaxError:
123
+ return set()
124
+ found: set[str] = set()
125
+ for node in ast.walk(tree):
126
+ if isinstance(node, ast.Return) and isinstance(node.value, ast.Constant):
127
+ if isinstance(node.value.value, str) and node.value.value.strip():
128
+ found.add(node.value.value.strip())
129
+ return found
130
+
131
+
132
+ def _request_bytes(body: Any) -> bytes:
133
+ if body is None:
134
+ return b""
135
+ if isinstance(body, bytes):
136
+ return body
137
+ if isinstance(body, str):
138
+ return body.encode("utf-8", errors="ignore")
139
+ if isinstance(body, dict):
140
+ return json.dumps(body).encode("utf-8")
141
+ return str(body).encode("utf-8", errors="ignore")
142
+
143
+
144
+ def _loads(body: bytes | str | None) -> Any:
145
+ if body is None:
146
+ return None
147
+ if isinstance(body, dict):
148
+ return body
149
+ try:
150
+ text = body.decode("utf-8", errors="ignore") if isinstance(body, bytes) else str(body)
151
+ return json.loads(text)
152
+ except (json.JSONDecodeError, UnicodeError, TypeError):
153
+ return None
154
+
155
+
156
+ def _url_of(url: Any) -> str:
157
+ full = getattr(url, "full_url", None)
158
+ return str(full or url or "")
159
+
160
+
161
+ def classify_exchange(url: str, request_body: bytes) -> dict[str, Any] | None:
162
+ """Return a small description when the request looks like a model call."""
163
+ lowered = (url or "").lower().split("?", 1)[0]
164
+ data = _loads(request_body)
165
+ shaped = any(hint in lowered for hint in _LLM_HINTS)
166
+ if isinstance(data, dict) and _BODY_KEYS.intersection(data):
167
+ shaped = True
168
+ if not shaped:
169
+ return None
170
+ kind = "chat_completions"
171
+ if "generatecontent" in lowered or "generativelanguage" in lowered:
172
+ kind = "generate_content"
173
+ elif "/v1/messages" in lowered:
174
+ kind = "messages"
175
+ elif "/api/chat" in lowered:
176
+ kind = "api_chat"
177
+ elif "/api/generate" in lowered:
178
+ kind = "api_generate"
179
+ elif "/v1/responses" in lowered:
180
+ kind = "responses"
181
+ model = ""
182
+ user_bits: list[str] = []
183
+ if isinstance(data, dict):
184
+ if isinstance(data.get("model"), str):
185
+ model = data["model"].strip()
186
+ messages = data.get("messages")
187
+ if isinstance(messages, list):
188
+ for item in messages:
189
+ if isinstance(item, dict) and item.get("role") != "system":
190
+ content = item.get("content")
191
+ if isinstance(content, str):
192
+ user_bits.append(content)
193
+ for key in ("prompt", "input"):
194
+ if isinstance(data.get(key), str):
195
+ user_bits.append(data[key])
196
+ contents = data.get("contents")
197
+ if isinstance(contents, list):
198
+ for item in contents:
199
+ parts = item.get("parts") if isinstance(item, dict) else None
200
+ if isinstance(parts, list):
201
+ for part in parts:
202
+ if isinstance(part, dict) and isinstance(part.get("text"), str):
203
+ user_bits.append(part["text"])
204
+ host = ""
205
+ try:
206
+ host = urllib.parse.urlparse(url).hostname or ""
207
+ except Exception:
208
+ host = ""
209
+ return {
210
+ "url_kind": kind,
211
+ "url_host": host.lower(),
212
+ "model": model,
213
+ "user_text": "\n".join(user_bits)[:4000],
214
+ }
215
+
216
+
217
+ def _response_texts(body: bytes) -> list[str]:
218
+ texts: list[str] = []
219
+ raw = body.decode("utf-8", errors="ignore") if isinstance(body, bytes) else str(body or "")
220
+ if raw.strip():
221
+ texts.append(raw)
222
+ data = _loads(body)
223
+ if isinstance(data, dict):
224
+ try:
225
+ content = data["choices"][0]["message"]["content"]
226
+ if isinstance(content, str):
227
+ texts.append(content)
228
+ except (KeyError, IndexError, TypeError):
229
+ pass
230
+ try:
231
+ content = data["candidates"][0]["content"]["parts"][0]["text"]
232
+ if isinstance(content, str):
233
+ texts.append(content)
234
+ except (KeyError, IndexError, TypeError):
235
+ pass
236
+ response = data.get("response")
237
+ if isinstance(response, str):
238
+ texts.append(response)
239
+ message = data.get("message")
240
+ if isinstance(message, dict) and isinstance(message.get("content"), str):
241
+ texts.append(message["content"])
242
+ return texts
243
+
244
+
245
+ def _used_response(result: str, response_body: bytes) -> bool:
246
+ normalized = " ".join(result.split())
247
+ if len(normalized) < 8:
248
+ return False
249
+ for text in _response_texts(response_body):
250
+ other = " ".join(text.split())
251
+ if not other:
252
+ continue
253
+ if normalized == other or normalized in other or (len(other) >= 8 and other in normalized):
254
+ return True
255
+ return False
256
+
257
+
258
+ def _is_echo(result: str, user_text: str) -> bool:
259
+ normalized = " ".join(result.split())
260
+ user = " ".join((user_text or "").split())
261
+ if not normalized or not user:
262
+ return False
263
+ return normalized == user or (len(normalized) > 40 and normalized in user)
264
+
265
+
266
+ def _active_span(tracer: Any) -> dict | None:
267
+ spans = getattr(tracer, "_llm_spans", None) if tracer is not None else None
268
+ if not spans:
269
+ return None
270
+ return spans[-1]
271
+
272
+
273
+ def observe_http(url: str, request_body: Any, status: int, response_body: bytes) -> None:
274
+ from hidevs_quest.graphify import ExecutionTracer
275
+
276
+ tracer = ExecutionTracer._active
277
+ span = _active_span(tracer)
278
+ if span is None:
279
+ return
280
+ info = classify_exchange(url, _request_bytes(request_body))
281
+ if info is None:
282
+ return
283
+ if not isinstance(response_body, (bytes, bytearray)):
284
+ response_body = _request_bytes(response_body)
285
+ # Keep only what the proof needs. Do not store the prompt or the completion.
286
+ span["http"].append({
287
+ **info,
288
+ "status": int(status or 0),
289
+ "response_body": bytes(response_body)[:200_000],
290
+ })
291
+
292
+
293
+ def _prove(span: dict, result: Any, probe: "LlmCallProbe") -> dict | None:
294
+ text = _result_text(result)
295
+ if not text or text in probe.constants:
296
+ return None
297
+ for event in span.get("http") or []:
298
+ if not (200 <= int(event.get("status") or 0) < 300):
299
+ continue
300
+ if _is_echo(text, event.get("user_text") or ""):
301
+ continue
302
+ if not _used_response(text, event.get("response_body") or b""):
303
+ continue
304
+ return {
305
+ "method": probe.name,
306
+ "file": probe.filename,
307
+ "model": event.get("model") or None,
308
+ "via": "http",
309
+ "proven": True,
310
+ "declared": True,
311
+ "url_kind": event.get("url_kind") or "",
312
+ "url_host": event.get("url_host") or "",
313
+ "input_fingerprint": _fingerprint(event.get("user_text") or ""),
314
+ "output_fingerprint": _fingerprint(text),
315
+ }
316
+ return None
317
+
318
+
319
+ class LlmCallProbe:
320
+ def __init__(self, original, declared_model: str = ""):
321
+ self.original = original
322
+ self.name = getattr(original, "__name__", "call")
323
+ self.filename = str(getattr(original, "__code__", None) and original.__code__.co_filename or "")
324
+ self.declared_model = declared_model
325
+ try:
326
+ self.constants = _return_constants(inspect.getsource(original))
327
+ except (OSError, TypeError):
328
+ self.constants = set()
329
+
330
+ def __call__(self, *args, **kwargs):
331
+ from hidevs_quest.graphify import ExecutionTracer
332
+
333
+ tracer = ExecutionTracer._active
334
+ span = {"http": []}
335
+ if tracer is not None:
336
+ tracer._llm_spans = getattr(tracer, "_llm_spans", [])
337
+ tracer._llm_spans.append(span)
338
+ try:
339
+ result = self.original(*args, **kwargs)
340
+ except BaseException:
341
+ if tracer is not None and tracer._llm_spans:
342
+ tracer._llm_spans.pop()
343
+ raise
344
+ if tracer is not None and tracer._llm_spans:
345
+ tracer._llm_spans.pop()
346
+ record = _prove(span, result, self)
347
+ if record is not None:
348
+ tracer.llm_calls.append(record)
349
+ tracer.nodes.add(f"llm:{self.name}")
350
+ return result
351
+
352
+
353
+ def _wrap_read(response, url: str, request_body: bytes):
354
+ original_read = response.read
355
+
356
+ def read(*args, **kwargs):
357
+ blob = original_read(*args, **kwargs)
358
+ try:
359
+ status = int(getattr(response, "status", None) or getattr(response, "code", None) or 200)
360
+ except (TypeError, ValueError):
361
+ status = 200
362
+ payload = blob if isinstance(blob, (bytes, bytearray)) else _request_bytes(blob)
363
+ observe_http(url, request_body, status, payload)
364
+ return blob
365
+
366
+ response.read = read
367
+ return response
368
+
369
+
370
+ def _wrapped_urlopen(original):
371
+ def urlopen(url, data=None, *args, **kwargs):
372
+ request_url = _url_of(url)
373
+ request_body = _request_bytes(getattr(url, "data", None) if data is None else data)
374
+ response = original(url, data, *args, **kwargs)
375
+ return _wrap_read(response, request_url, request_body)
376
+
377
+ return urlopen
378
+
379
+
380
+ def install_http_hooks() -> None:
381
+ """Patch HTTP clients so a declared model call can be proved. Idempotent."""
382
+ if _HOOKS:
383
+ return
384
+ original = urllib.request.urlopen
385
+ urllib.request.urlopen = _wrapped_urlopen(original)
386
+ _HOOKS.append((urllib.request, "urlopen", original))
387
+
388
+ try:
389
+ import httpx
390
+ except Exception:
391
+ httpx = None
392
+ if httpx is not None and hasattr(httpx.Client, "send"):
393
+ original_send = httpx.Client.send
394
+
395
+ def send(self, request, *args, **kwargs):
396
+ response = original_send(self, request, *args, **kwargs)
397
+ try:
398
+ observe_http(
399
+ str(request.url),
400
+ getattr(request, "content", b"") or b"",
401
+ int(getattr(response, "status_code", 0) or 0),
402
+ response.content or b"",
403
+ )
404
+ except Exception:
405
+ pass
406
+ return response
407
+
408
+ httpx.Client.send = send
409
+ _HOOKS.append((httpx.Client, "send", original_send))
410
+
411
+ try:
412
+ import requests
413
+ except Exception:
414
+ requests = None
415
+ if requests is not None:
416
+ original_request = requests.sessions.Session.request
417
+
418
+ def request(self, method, url, *args, **kwargs):
419
+ response = original_request(self, method, url, *args, **kwargs)
420
+ body = kwargs.get("data")
421
+ if body is None and kwargs.get("json") is not None:
422
+ body = kwargs.get("json")
423
+ try:
424
+ observe_http(str(response.url or url), body, int(response.status_code or 0), response.content or b"")
425
+ except Exception:
426
+ pass
427
+ return response
428
+
429
+ requests.sessions.Session.request = request
430
+ _HOOKS.append((requests.sessions.Session, "request", original_request))
431
+
432
+
433
+ def remove_http_hooks() -> None:
434
+ while _HOOKS:
435
+ owner, name, original = _HOOKS.pop()
436
+ setattr(owner, name, original)
@@ -139,6 +139,7 @@ class Scripted:
139
139
  self.mode = mode
140
140
  self.calls = []
141
141
  self.arg_records = []
142
+ self.results = []
142
143
  self.node_id = node_id
143
144
 
144
145
  def _note(self) -> None:
@@ -165,7 +166,11 @@ class Scripted:
165
166
  raise RuntimeError("injected tool failure")
166
167
  if self.mode == "timeout":
167
168
  raise TimeoutError("injected tool timeout")
168
- return self.original(*args, **kwargs)
169
+ result = self.original(*args, **kwargs)
170
+ from hidevs_quest.llm_probe import clip_value
171
+
172
+ self.results.append(clip_value(result))
173
+ return result
169
174
 
170
175
 
171
176
  def modules_under(root: Path):
@@ -230,6 +235,33 @@ def restore(restores) -> None:
230
235
  setattr(owner, key, original)
231
236
 
232
237
 
238
+ def patch_llm(root: Path, config: dict):
239
+ """Wrap requirements.llm.call when it is a real module function.
240
+
241
+ Instance calls such as llm.invoke stay on the SDK tracer. The wrapper
242
+ records a call only after llm_probe proves a model response was used.
243
+ """
244
+ from hidevs_quest.llm_probe import LlmCallProbe, resolve_llm_callable
245
+
246
+ claim = ((config.get("requirements") or {}).get("llm")) or {}
247
+ found = resolve_llm_callable(root, str(claim.get("call") or ""))
248
+ if not found:
249
+ return []
250
+ module, name, original = found
251
+ if isinstance(original, LlmCallProbe):
252
+ original = original.original
253
+ probe = LlmCallProbe(original, declared_model=str(claim.get("model") or ""))
254
+ restores = [(module, name, original)]
255
+ setattr(module, name, probe)
256
+ for loaded in modules_under(root):
257
+ for attr, value in list(vars(loaded).items()):
258
+ current = value.original if isinstance(value, LlmCallProbe) else value
259
+ if current is original:
260
+ setattr(loaded, attr, probe)
261
+ restores.append((loaded, attr, original))
262
+ return restores
263
+
264
+
233
265
  from hidevs_quest.graphify import ExecutionTracer, build_ast_graph, summarize_tool_args
234
266
 
235
267
 
@@ -255,50 +287,61 @@ def run_cases(config: dict, root: Path, cases: list[dict]) -> tuple[list[dict],
255
287
  ast_graph = build_ast_graph(sources, method, config.get("tools"))
256
288
  (artifacts_dir / "ast_graph.json").write_text(json.dumps(ast_graph, indent=2))
257
289
 
258
- for case in cases:
259
- mode = case.get("tool_mode") or "ok"
260
- wrappers, restores = patch_tools(root, config, mode)
261
- runtime_graph = {}
262
- try:
290
+ from hidevs_quest.llm_probe import install_http_hooks, remove_http_hooks
291
+
292
+ install_http_hooks()
293
+ try:
294
+ for case in cases:
295
+ mode = case.get("tool_mode") or "ok"
296
+ wrappers, restores = patch_tools(root, config, mode)
297
+ restores.extend(patch_llm(root, config))
298
+ runtime_graph = {}
263
299
  try:
264
- with ExecutionTracer(root) as tracer:
265
- payload = as_dict(invoke(config, root, case))
266
- runtime_graph = tracer.get_graph()
267
- error = ""
268
- except Exception:
269
- payload = {}
270
- error = traceback.format_exc()
271
- finally:
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
-
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
286
- results.append({
287
- "id": case["id"],
288
- "payload": payload,
289
- "error": error[-1500:] if error else "",
290
- "counts": counts,
291
- "runtime_graph": runtime_graph,
292
- })
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
300
+ try:
301
+ with ExecutionTracer(root) as tracer:
302
+ payload = as_dict(invoke(config, root, case))
303
+ runtime_graph = tracer.get_graph()
304
+ error = ""
305
+ except Exception:
306
+ payload = {}
307
+ error = traceback.format_exc()
308
+ finally:
309
+ restore(restores)
310
+
311
+ tool_arg_calls = {}
312
+ for name, wrapper in wrappers.items():
313
+ tool_arg_calls[name] = list(wrapper.arg_records)
314
+ tool_calls_by_cap.setdefault(name, []).extend(
315
+ {**rec, "case_id": case["id"]} for rec in wrapper.arg_records
316
+ )
317
+ runtime_graph["tool_arg_calls"] = tool_arg_calls
318
+
319
+ counts = {name: len(wrapper.calls) for name, wrapper in wrappers.items()}
320
+ runtime_graph = dict(runtime_graph or {})
321
+ runtime_graph["counts"] = counts
322
+ runtime_graph["tool_results"] = {
323
+ name: list(wrapper.results[:3])
324
+ for name, wrapper in wrappers.items()
325
+ if wrapper.results
326
+ }
327
+ all_runtime_graphs[case["id"]] = runtime_graph
328
+ results.append({
329
+ "id": case["id"],
330
+ "payload": payload,
331
+ "error": error[-1500:] if error else "",
332
+ "counts": counts,
333
+ "runtime_graph": runtime_graph,
334
+ })
335
+
336
+ tool_arg_summary = summarize_tool_args(tool_calls_by_cap)
337
+ all_llm_calls = []
338
+ for graph in all_runtime_graphs.values():
339
+ graph["tool_arg_summary"] = tool_arg_summary
340
+ all_llm_calls.extend(graph.get("llm_calls") or [])
341
+ for row in results:
342
+ row["runtime_graph"]["tool_arg_summary"] = tool_arg_summary
343
+ finally:
344
+ remove_http_hooks()
302
345
 
303
346
  (artifacts_dir / "execution_trace_graph.json").write_text(json.dumps(all_runtime_graphs, indent=2))
304
347
  (artifacts_dir / "tool_arg_summary.json").write_text(json.dumps(tool_arg_summary, indent=2))
@@ -1 +0,0 @@
1
- __version__ = "0.1.2"
File without changes
File without changes