10xgraph-api 0.7.0__py3-none-any.whl

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.
Files changed (203) hide show
  1. 10xgraph_api-0.7.0.dist-info/METADATA +618 -0
  2. 10xgraph_api-0.7.0.dist-info/RECORD +203 -0
  3. 10xgraph_api-0.7.0.dist-info/WHEEL +5 -0
  4. 10xgraph_api-0.7.0.dist-info/entry_points.txt +3 -0
  5. 10xgraph_api-0.7.0.dist-info/licenses/LICENSE +21 -0
  6. 10xgraph_api-0.7.0.dist-info/top_level.txt +2 -0
  7. agentflow_cli/__init__.py +69 -0
  8. tenxgraph_api/__init__.py +23 -0
  9. tenxgraph_api/cli/__init__.py +6 -0
  10. tenxgraph_api/cli/capabilities.py +127 -0
  11. tenxgraph_api/cli/commands/__init__.py +49 -0
  12. tenxgraph_api/cli/commands/api.py +262 -0
  13. tenxgraph_api/cli/commands/audit.py +213 -0
  14. tenxgraph_api/cli/commands/build.py +305 -0
  15. tenxgraph_api/cli/commands/config.py +71 -0
  16. tenxgraph_api/cli/commands/demo.py +93 -0
  17. tenxgraph_api/cli/commands/eval.py +909 -0
  18. tenxgraph_api/cli/commands/init.py +531 -0
  19. tenxgraph_api/cli/commands/skills.py +532 -0
  20. tenxgraph_api/cli/commands/test.py +113 -0
  21. tenxgraph_api/cli/commands/version.py +47 -0
  22. tenxgraph_api/cli/config_editor/__init__.py +15 -0
  23. tenxgraph_api/cli/config_editor/schema.py +566 -0
  24. tenxgraph_api/cli/config_editor/server.py +240 -0
  25. tenxgraph_api/cli/config_editor/static/app.css +2 -0
  26. tenxgraph_api/cli/config_editor/static/app.js +3 -0
  27. tenxgraph_api/cli/config_editor/static/favicon.svg +11 -0
  28. tenxgraph_api/cli/config_editor/static/index.html +20 -0
  29. tenxgraph_api/cli/config_editor/store.py +120 -0
  30. tenxgraph_api/cli/config_editor/validation.py +377 -0
  31. tenxgraph_api/cli/constants.py +112 -0
  32. tenxgraph_api/cli/context.py +46 -0
  33. tenxgraph_api/cli/core/__init__.py +0 -0
  34. tenxgraph_api/cli/core/animations.py +550 -0
  35. tenxgraph_api/cli/core/config.py +279 -0
  36. tenxgraph_api/cli/core/output.py +625 -0
  37. tenxgraph_api/cli/core/prompts.py +220 -0
  38. tenxgraph_api/cli/core/screen.py +287 -0
  39. tenxgraph_api/cli/core/steps.py +621 -0
  40. tenxgraph_api/cli/core/theme.py +242 -0
  41. tenxgraph_api/cli/core/validation.py +251 -0
  42. tenxgraph_api/cli/exceptions.py +129 -0
  43. tenxgraph_api/cli/logger.py +84 -0
  44. tenxgraph_api/cli/main.py +936 -0
  45. tenxgraph_api/cli/templates/__init__.py +0 -0
  46. tenxgraph_api/cli/templates/defaults.py +844 -0
  47. tenxgraph_api/cli/templates/dev/.env.example +1 -0
  48. tenxgraph_api/cli/templates/dev/10xgraph.json +16 -0
  49. tenxgraph_api/cli/templates/dev/graph/__init__.py +0 -0
  50. tenxgraph_api/cli/templates/dev/graph/agent.py +63 -0
  51. tenxgraph_api/cli/templates/prod/.env.example +75 -0
  52. tenxgraph_api/cli/templates/prod/.pre-commit-config.yaml +50 -0
  53. tenxgraph_api/cli/templates/prod/.python-version +1 -0
  54. tenxgraph_api/cli/templates/prod/10xgraph.json +42 -0
  55. tenxgraph_api/cli/templates/prod/auth/__init__.py +0 -0
  56. tenxgraph_api/cli/templates/prod/auth/agent_auth.py +46 -0
  57. tenxgraph_api/cli/templates/prod/evals/__init__.py +0 -0
  58. tenxgraph_api/cli/templates/prod/evals/user_simulator_eval.py +76 -0
  59. tenxgraph_api/cli/templates/prod/evals/weather_agents_eval.py +53 -0
  60. tenxgraph_api/cli/templates/prod/graph/__init__.py +0 -0
  61. tenxgraph_api/cli/templates/prod/graph/agent.py +50 -0
  62. tenxgraph_api/cli/templates/prod/graph/state.py +5 -0
  63. tenxgraph_api/cli/templates/prod/graph/thread_name_generator.py +7 -0
  64. tenxgraph_api/cli/templates/prod/graph/tools/__init__.py +0 -0
  65. tenxgraph_api/cli/templates/prod/graph/tools/weather_tool.py +43 -0
  66. tenxgraph_api/cli/templates/prod/graph/validators/__init__.py +0 -0
  67. tenxgraph_api/cli/templates/prod/graph/validators/lifecyle.py +107 -0
  68. tenxgraph_api/cli/templates/prod/graph/validators/manager.py +9 -0
  69. tenxgraph_api/cli/templates/prod/graph/validators/validators.py +21 -0
  70. tenxgraph_api/cli/templates/prod/pyproject.toml +79 -0
  71. tenxgraph_api/cli/templates/prod/tests/__init__.py +0 -0
  72. tenxgraph_api/cli/templates/prod/tests/conftest.py +6 -0
  73. tenxgraph_api/cli/templates/prod/tests/test_agent_eval.py +78 -0
  74. tenxgraph_api/cli/templates/prod/tests/test_catalog_tools.py +47 -0
  75. tenxgraph_api/cli/templates/prod/tests/test_graph_nodes.py +57 -0
  76. tenxgraph_api/cli/templates/skills/10xgraph/SKILL.md +92 -0
  77. tenxgraph_api/cli/templates/skills/10xgraph/references/agents-and-tools.md +389 -0
  78. tenxgraph_api/cli/templates/skills/10xgraph/references/api-client.md +61 -0
  79. tenxgraph_api/cli/templates/skills/10xgraph/references/api-configuration.md +75 -0
  80. tenxgraph_api/cli/templates/skills/10xgraph/references/api-settings-and-middleware.md +89 -0
  81. tenxgraph_api/cli/templates/skills/10xgraph/references/architecture.md +91 -0
  82. tenxgraph_api/cli/templates/skills/10xgraph/references/auth-and-authorization.md +149 -0
  83. tenxgraph_api/cli/templates/skills/10xgraph/references/callbacks-and-command.md +283 -0
  84. tenxgraph_api/cli/templates/skills/10xgraph/references/checkpointing-and-threads.md +63 -0
  85. tenxgraph_api/cli/templates/skills/10xgraph/references/cli-commands.md +73 -0
  86. tenxgraph_api/cli/templates/skills/10xgraph/references/client-auth-and-errors.md +94 -0
  87. tenxgraph_api/cli/templates/skills/10xgraph/references/client-messages-invoke-stream.md +115 -0
  88. tenxgraph_api/cli/templates/skills/10xgraph/references/client-threads-memory-files.md +80 -0
  89. tenxgraph_api/cli/templates/skills/10xgraph/references/context-id-background.md +80 -0
  90. tenxgraph_api/cli/templates/skills/10xgraph/references/dependency-injection.md +58 -0
  91. tenxgraph_api/cli/templates/skills/10xgraph/references/evaluation.md +500 -0
  92. tenxgraph_api/cli/templates/skills/10xgraph/references/id-and-thread-name-generators.md +69 -0
  93. tenxgraph_api/cli/templates/skills/10xgraph/references/media-and-files.md +66 -0
  94. tenxgraph_api/cli/templates/skills/10xgraph/references/memory-and-store.md +70 -0
  95. tenxgraph_api/cli/templates/skills/10xgraph/references/prebuilt-agents-and-tools.md +394 -0
  96. tenxgraph_api/cli/templates/skills/10xgraph/references/production-runtime.md +56 -0
  97. tenxgraph_api/cli/templates/skills/10xgraph/references/providers-and-adapters.md +89 -0
  98. tenxgraph_api/cli/templates/skills/10xgraph/references/publishers-and-runtime-protocols.md +84 -0
  99. tenxgraph_api/cli/templates/skills/10xgraph/references/rate-limiting.md +194 -0
  100. tenxgraph_api/cli/templates/skills/10xgraph/references/realtime.md +172 -0
  101. tenxgraph_api/cli/templates/skills/10xgraph/references/remote-tools.md +55 -0
  102. tenxgraph_api/cli/templates/skills/10xgraph/references/rest-api-and-errors.md +97 -0
  103. tenxgraph_api/cli/templates/skills/10xgraph/references/security-and-validators.md +86 -0
  104. tenxgraph_api/cli/templates/skills/10xgraph/references/state-and-messages.md +68 -0
  105. tenxgraph_api/cli/templates/skills/10xgraph/references/state-graph.md +232 -0
  106. tenxgraph_api/cli/templates/skills/10xgraph/references/stream-emitter.md +144 -0
  107. tenxgraph_api/cli/templates/skills/10xgraph/references/streaming.md +53 -0
  108. tenxgraph_api/cli/templates/skills/10xgraph/references/testing-and-evaluation.md +64 -0
  109. tenxgraph_api/cli/templates/skills/10xgraph/references/unit-testing.md +202 -0
  110. tenxgraph_api/cli/templates/skills/copilot/10xgraph.instructions.md +108 -0
  111. tenxgraph_api/py.typed +0 -0
  112. tenxgraph_api/src/__init__.py +0 -0
  113. tenxgraph_api/src/app/__init__.py +13 -0
  114. tenxgraph_api/src/app/core/__init__.py +15 -0
  115. tenxgraph_api/src/app/core/auth/__init__.py +0 -0
  116. tenxgraph_api/src/app/core/auth/auth_backend.py +38 -0
  117. tenxgraph_api/src/app/core/auth/authorization.py +379 -0
  118. tenxgraph_api/src/app/core/auth/base_auth.py +27 -0
  119. tenxgraph_api/src/app/core/auth/jwt_auth.py +122 -0
  120. tenxgraph_api/src/app/core/auth/ownership_resolver.py +170 -0
  121. tenxgraph_api/src/app/core/auth/permissions.py +354 -0
  122. tenxgraph_api/src/app/core/auth/request_config.py +130 -0
  123. tenxgraph_api/src/app/core/auth/route_guard.py +105 -0
  124. tenxgraph_api/src/app/core/config/__init__.py +0 -0
  125. tenxgraph_api/src/app/core/config/graph_config.py +532 -0
  126. tenxgraph_api/src/app/core/config/media_settings.py +72 -0
  127. tenxgraph_api/src/app/core/config/sentry_config.py +60 -0
  128. tenxgraph_api/src/app/core/config/settings.py +202 -0
  129. tenxgraph_api/src/app/core/config/setup_logs.py +65 -0
  130. tenxgraph_api/src/app/core/config/setup_middleware.py +483 -0
  131. tenxgraph_api/src/app/core/config/worker_middleware.py +99 -0
  132. tenxgraph_api/src/app/core/exceptions/__init__.py +17 -0
  133. tenxgraph_api/src/app/core/exceptions/general_exception.py +58 -0
  134. tenxgraph_api/src/app/core/exceptions/handle_errors.py +363 -0
  135. tenxgraph_api/src/app/core/exceptions/resources_exceptions.py +72 -0
  136. tenxgraph_api/src/app/core/exceptions/user_exception.py +58 -0
  137. tenxgraph_api/src/app/core/middleware/__init__.py +25 -0
  138. tenxgraph_api/src/app/core/middleware/rate_limit/__init__.py +15 -0
  139. tenxgraph_api/src/app/core/middleware/rate_limit/base.py +27 -0
  140. tenxgraph_api/src/app/core/middleware/rate_limit/factory.py +98 -0
  141. tenxgraph_api/src/app/core/middleware/rate_limit/keying.py +133 -0
  142. tenxgraph_api/src/app/core/middleware/rate_limit/memory.py +98 -0
  143. tenxgraph_api/src/app/core/middleware/rate_limit/middleware.py +90 -0
  144. tenxgraph_api/src/app/core/middleware/rate_limit/redis.py +115 -0
  145. tenxgraph_api/src/app/core/middleware/request_limits.py +154 -0
  146. tenxgraph_api/src/app/core/middleware/security_headers.py +239 -0
  147. tenxgraph_api/src/app/core/utils/__init__.py +14 -0
  148. tenxgraph_api/src/app/core/utils/log_sanitizer.py +216 -0
  149. tenxgraph_api/src/app/loader.py +516 -0
  150. tenxgraph_api/src/app/main.py +180 -0
  151. tenxgraph_api/src/app/routers/__init__.py +4 -0
  152. tenxgraph_api/src/app/routers/ag_ui/__init__.py +10 -0
  153. tenxgraph_api/src/app/routers/ag_ui/converter.py +195 -0
  154. tenxgraph_api/src/app/routers/ag_ui/event_mapper.py +353 -0
  155. tenxgraph_api/src/app/routers/ag_ui/router.py +90 -0
  156. tenxgraph_api/src/app/routers/ag_ui/service.py +193 -0
  157. tenxgraph_api/src/app/routers/checkpointer/__init__.py +1 -0
  158. tenxgraph_api/src/app/routers/checkpointer/router.py +482 -0
  159. tenxgraph_api/src/app/routers/checkpointer/schemas/__init__.py +1 -0
  160. tenxgraph_api/src/app/routers/checkpointer/schemas/checkpointer_schemas.py +121 -0
  161. tenxgraph_api/src/app/routers/checkpointer/services/__init__.py +1 -0
  162. tenxgraph_api/src/app/routers/checkpointer/services/checkpointer_service.py +300 -0
  163. tenxgraph_api/src/app/routers/evals/__init__.py +6 -0
  164. tenxgraph_api/src/app/routers/evals/router.py +38 -0
  165. tenxgraph_api/src/app/routers/evals/services/__init__.py +1 -0
  166. tenxgraph_api/src/app/routers/evals/services/eval_report_service.py +354 -0
  167. tenxgraph_api/src/app/routers/graph/__init__.py +6 -0
  168. tenxgraph_api/src/app/routers/graph/realtime_guard.py +161 -0
  169. tenxgraph_api/src/app/routers/graph/router.py +821 -0
  170. tenxgraph_api/src/app/routers/graph/schemas/__init__.py +1 -0
  171. tenxgraph_api/src/app/routers/graph/schemas/graph_schemas.py +386 -0
  172. tenxgraph_api/src/app/routers/graph/services/__init__.py +1 -0
  173. tenxgraph_api/src/app/routers/graph/services/graph_service.py +1219 -0
  174. tenxgraph_api/src/app/routers/graph/services/multimodal_preprocessor.py +164 -0
  175. tenxgraph_api/src/app/routers/media/__init__.py +506 -0
  176. tenxgraph_api/src/app/routers/media/router.py +242 -0
  177. tenxgraph_api/src/app/routers/media/schemas.py +39 -0
  178. tenxgraph_api/src/app/routers/ping/__init__.py +4 -0
  179. tenxgraph_api/src/app/routers/ping/router.py +28 -0
  180. tenxgraph_api/src/app/routers/setup_router.py +60 -0
  181. tenxgraph_api/src/app/routers/store/__init__.py +4 -0
  182. tenxgraph_api/src/app/routers/store/router.py +200 -0
  183. tenxgraph_api/src/app/routers/store/schemas/__init__.py +28 -0
  184. tenxgraph_api/src/app/routers/store/schemas/store_schemas.py +200 -0
  185. tenxgraph_api/src/app/routers/store/services/__init__.py +4 -0
  186. tenxgraph_api/src/app/routers/store/services/store_service.py +203 -0
  187. tenxgraph_api/src/app/tasks/__init__.py +0 -0
  188. tenxgraph_api/src/app/tasks/user_tasks.py +38 -0
  189. tenxgraph_api/src/app/utils/__init__.py +12 -0
  190. tenxgraph_api/src/app/utils/callable_helper.py +26 -0
  191. tenxgraph_api/src/app/utils/media/__init__.py +15 -0
  192. tenxgraph_api/src/app/utils/media/extractor.py +172 -0
  193. tenxgraph_api/src/app/utils/media/pipeline.py +83 -0
  194. tenxgraph_api/src/app/utils/parse_output.py +17 -0
  195. tenxgraph_api/src/app/utils/response_helper.py +129 -0
  196. tenxgraph_api/src/app/utils/schemas/__init__.py +11 -0
  197. tenxgraph_api/src/app/utils/schemas/output_schemas.py +28 -0
  198. tenxgraph_api/src/app/utils/schemas/user_schemas.py +13 -0
  199. tenxgraph_api/src/app/utils/snowflake_id_generator.py +90 -0
  200. tenxgraph_api/src/app/utils/swagger_helper.py +160 -0
  201. tenxgraph_api/src/app/utils/telemetry_store.py +123 -0
  202. tenxgraph_api/src/app/utils/thread_name_generator.py +304 -0
  203. tenxgraph_api/src/app/worker.py +91 -0
@@ -0,0 +1,909 @@
1
+ """Eval command — discover and run 10xGraph evaluations, always generating reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import importlib
7
+ import importlib.util
8
+ import inspect
9
+ import sys
10
+ import typing
11
+ import webbrowser
12
+ from collections import defaultdict
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from injectq.testing import override_dependency
18
+ from tenxgraph.qa.evaluation import CriteriaConfig, CriterionConfig, EvalConfig
19
+ from tenxgraph.qa.evaluation.collectors.trajectory_collector import (
20
+ TrajectoryCollector,
21
+ make_trajectory_callback,
22
+ )
23
+ from tenxgraph.qa.evaluation.config.eval_config import ReporterConfig
24
+ from tenxgraph.qa.evaluation.eval_result import EvalCaseResult
25
+ from tenxgraph.qa.evaluation.eval_result import EvalReport as ER
26
+ from tenxgraph.qa.evaluation.evaluator import AgentEvaluator
27
+ from tenxgraph.qa.evaluation.reporters.manager import ReporterManager
28
+ from tenxgraph.utils.callbacks import CallbackManager
29
+
30
+ from tenxgraph_api.cli.commands import BaseCommand
31
+ from tenxgraph_api.cli.core.config import ConfigManager
32
+
33
+
34
+ if TYPE_CHECKING:
35
+ from tenxgraph.qa.evaluation.eval_result import EvalReport
36
+
37
+
38
+ @dataclass
39
+ class _PendingCase:
40
+ case: Any # EvalCase
41
+ evaluator: AgentEvaluator
42
+ config: Any # EvalConfig — the resolved config for this case
43
+ file_name: str
44
+ eval_set_id: str
45
+ eval_set_name: str
46
+ config_source: str = "" # where config came from: per-file / confeval.py / defaults
47
+
48
+
49
+ @dataclass
50
+ class _PendingSimulation:
51
+ scenario: Any # ConversationScenario
52
+ graph: Any
53
+ simulator: Any # UserSimulator
54
+ file_name: str
55
+ eval_set_id: str
56
+ eval_set_name: str
57
+
58
+
59
+ def _reset_inject_proxy(service_type: type) -> None:
60
+ """Reset the lazy Inject[T] proxy cached on Node.execute's default parameters.
61
+
62
+ Inject[T] proxies cache their resolved value in _injected_value on first use.
63
+ container.override() clears the singleton scope, but the proxy's own cache is
64
+ independent — it must be cleared so the proxy re-resolves from the container
65
+ on next invocation instead of returning the stale value.
66
+ """
67
+ try:
68
+ from tenxgraph.core.graph.node import Node as _GraphNode
69
+
70
+ defaults = getattr(_GraphNode.execute, "__defaults__", None) or ()
71
+ for d in defaults:
72
+ if (
73
+ hasattr(d, "_injected_value")
74
+ and hasattr(d, "service_type")
75
+ and d.service_type is service_type
76
+ ):
77
+ d._injected_value = None
78
+ except Exception: # nosec B110 # noqa: S110
79
+ pass
80
+
81
+
82
+ DEFAULT_CONCURRENCY = 4
83
+
84
+
85
+ class EvalCommand(BaseCommand):
86
+ """Discover and run agent evaluations; always write HTML + JSON reports."""
87
+
88
+ # ------------------------------------------------------------------
89
+ # Discovery
90
+ # ------------------------------------------------------------------
91
+
92
+ def _discover(self, target: Path) -> list[Path]:
93
+ """Return eval files under target. If target is a file, return it directly."""
94
+ if target.is_file():
95
+ return [target]
96
+
97
+ seen: dict[Path, None] = {}
98
+ for pattern in ("*_eval.py", "eval_*.py"):
99
+ for p in sorted(target.rglob(pattern)):
100
+ seen[p] = None
101
+ return list(seen)
102
+
103
+ # ------------------------------------------------------------------
104
+ # Module loading
105
+ # ------------------------------------------------------------------
106
+
107
+ def _load_module(self, path: Path) -> Any:
108
+ project_root = str(Path.cwd())
109
+ if project_root not in sys.path:
110
+ sys.path.insert(0, project_root)
111
+
112
+ spec = importlib.util.spec_from_file_location("_tenxgraph_eval", path)
113
+ mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
114
+ spec.loader.exec_module(mod) # type: ignore[union-attr]
115
+ return mod
116
+
117
+ # ------------------------------------------------------------------
118
+ # Agent loading from 10xgraph.json
119
+ # ------------------------------------------------------------------
120
+
121
+ def _load_agent_from_config(self) -> Any:
122
+ config_manager = ConfigManager()
123
+ discovered = config_manager.auto_discover_config()
124
+ if not discovered:
125
+ raise RuntimeError("No 10xgraph.json found — cannot auto-load agent.")
126
+ config_manager.load_config(str(discovered))
127
+ agent_spec: str = config_manager.get_config_value("agent", default="")
128
+ if not agent_spec or ":" not in agent_spec:
129
+ raise RuntimeError(f"Invalid 'agent' field in 10xgraph.json: {agent_spec!r}")
130
+ module_path, attr = agent_spec.rsplit(":", 1)
131
+ mod = importlib.import_module(module_path)
132
+ return getattr(mod, attr)
133
+
134
+ # ------------------------------------------------------------------
135
+ # Default config
136
+ # ------------------------------------------------------------------
137
+
138
+ def _default_config(self) -> EvalConfig:
139
+ return EvalConfig(
140
+ criteria=CriteriaConfig(
141
+ tool_name_match=CriterionConfig.tool_name_match(threshold=1.0),
142
+ # response_match=CriterionConfig.response_match(threshold=0.8),
143
+ # hallucinations=CriterionConfig.hallucination(threshold=0.8),
144
+ rouge_match=CriterionConfig.rouge_match(threshold=0.5),
145
+ node_order=CriterionConfig.node_order(threshold=0.8),
146
+ )
147
+ )
148
+
149
+ # ------------------------------------------------------------------
150
+ # confeval.py loading
151
+ # ------------------------------------------------------------------
152
+
153
+ def _load_confeval(self, evals_dir: Path) -> EvalConfig | None:
154
+ """Find and load confeval.py from evals_dir, returning its EvalConfig.
155
+
156
+ Tries get_eval_config() first, then falls back to the EVAL_CONFIG
157
+ constant. Returns None if no confeval.py is found so callers can
158
+ distinguish "no confeval" from "confeval with empty criteria".
159
+ """
160
+ confeval_path = evals_dir / "confeval.py"
161
+ if not confeval_path.exists():
162
+ return None
163
+
164
+ try:
165
+ mod = self._load_module(confeval_path)
166
+ except Exception as exc:
167
+ self.logger.warning("Failed to load confeval.py: %s", exc)
168
+ return None
169
+
170
+ if hasattr(mod, "get_eval_config"):
171
+ try:
172
+ return mod.get_eval_config()
173
+ except Exception as exc:
174
+ self.logger.warning("get_eval_config() in confeval.py failed: %s", exc)
175
+
176
+ if hasattr(mod, "EVAL_CONFIG"):
177
+ return mod.EVAL_CONFIG
178
+
179
+ self.logger.warning(
180
+ "confeval.py found but has no get_eval_config() or EVAL_CONFIG — ignoring"
181
+ )
182
+ return None
183
+
184
+ def _confeval_search_dirs(self, target_path: Path, evals_dir: Path) -> list[Path]:
185
+ """Directories to search for a global confeval.py, nearest-first.
186
+
187
+ confeval.py is the GLOBAL criteria config, so its discovery must not
188
+ depend on what the user targets. Walk up from the target (its parent
189
+ when the target is a file) toward the current working directory, then
190
+ append the configured evals directory. This way evals/confeval.py is
191
+ found whether the user runs the whole evals/ dir, a subfolder, or a
192
+ single file inside it.
193
+ """
194
+ start = target_path.parent if target_path.is_file() else target_path
195
+ try:
196
+ cur = start.resolve()
197
+ cwd = Path.cwd().resolve()
198
+ except OSError:
199
+ return [evals_dir]
200
+
201
+ dirs: list[Path] = []
202
+ while True:
203
+ dirs.append(cur)
204
+ if cur in (cwd, cur.parent):
205
+ break
206
+ cur = cur.parent
207
+
208
+ ev = evals_dir.resolve()
209
+ if ev not in dirs:
210
+ dirs.append(ev)
211
+ return dirs
212
+
213
+ def _resolve_confeval(
214
+ self, target_path: Path, evals_dir: Path
215
+ ) -> tuple[EvalConfig | None, Path | None]:
216
+ """Find and load the nearest global confeval.py.
217
+
218
+ Returns (config, path_to_confeval) for the first directory that yields a
219
+ usable confeval.py, or (None, None) when none is found so callers fall
220
+ back to per-file config and then the built-in defaults.
221
+ """
222
+ for d in self._confeval_search_dirs(target_path, evals_dir):
223
+ cfg = self._load_confeval(d)
224
+ if cfg is not None:
225
+ return cfg, d / "confeval.py"
226
+ return None, None
227
+
228
+ def _collect_eval_functions(self, mod: Any) -> tuple[list[tuple[str, Any]], Any]:
229
+ """Pytest-style discovery: functions annotated -> EvalSet are evals, ->
230
+ EvalConfig is config."""
231
+ from tenxgraph.qa.evaluation import EvalConfig, EvalSet
232
+
233
+ eval_pairs: list[tuple[str, Any]] = []
234
+ config: Any = None
235
+
236
+ for name, obj in inspect.getmembers(mod, inspect.isfunction):
237
+ if name.startswith("_"):
238
+ continue
239
+ if getattr(obj, "__module__", None) != getattr(mod, "__name__", None):
240
+ continue
241
+ hints: dict[str, Any] = {}
242
+ try:
243
+ hints = typing.get_type_hints(obj)
244
+ except Exception:
245
+ hints = getattr(obj, "__annotations__", {})
246
+
247
+ ret = hints.get("return")
248
+ if ret is None:
249
+ continue
250
+
251
+ try:
252
+ if inspect.isclass(ret) and issubclass(ret, EvalSet):
253
+ eval_pairs.append((name, obj()))
254
+ continue
255
+ if inspect.isclass(ret) and issubclass(ret, EvalConfig) and config is None:
256
+ config = obj()
257
+ except Exception as exc:
258
+ self.logger.warning("Could not call %s(): %s", name, exc)
259
+
260
+ return eval_pairs, config
261
+
262
+ # ------------------------------------------------------------------
263
+ # Flat pool: collect all pending cases from a file
264
+ # ------------------------------------------------------------------
265
+
266
+ def _collect_from_file(
267
+ self, path: Path, global_config: EvalConfig | None
268
+ ) -> list[_PendingCase | _PendingSimulation]:
269
+ """Load a module and return pending work for every eval case or simulation scenario.
270
+
271
+ Config priority chain (highest → lowest):
272
+ 1. Per-file — EVAL_CONFIG / get_eval_config() inside the file
273
+ 2. confeval.py — global_config (fallback when no per-file config)
274
+ 3. Built-in defaults — _default_config()
275
+
276
+ Returns _PendingSimulation items when the file exposes get_scenarios() or SCENARIOS.
277
+ Returns _PendingCase items for the standard get_eval_set() / pytest-style protocols.
278
+ """
279
+ mod = self._load_module(path)
280
+ file_name = path.name
281
+
282
+ # Simulator protocol — get_scenarios() or SCENARIOS constant
283
+ scenarios = None
284
+ if hasattr(mod, "get_scenarios"):
285
+ try:
286
+ scenarios = mod.get_scenarios()
287
+ except Exception as exc:
288
+ self.logger.warning("Could not call get_scenarios() in %s: %s", file_name, exc)
289
+ elif hasattr(mod, "SCENARIOS"):
290
+ scenarios = mod.SCENARIOS
291
+
292
+ if scenarios is not None:
293
+ return self._collect_simulations(mod, scenarios, file_name)
294
+
295
+ # get_eval_set() protocol
296
+ if hasattr(mod, "get_eval_set"):
297
+ file_config: EvalConfig | None = None
298
+ if hasattr(mod, "get_eval_config"):
299
+ file_config = mod.get_eval_config()
300
+ elif hasattr(mod, "EVAL_CONFIG"):
301
+ file_config = mod.EVAL_CONFIG
302
+ config, source = self._resolve_case_config(file_config, global_config)
303
+ return self._make_pending(mod, mod.get_eval_set(), config, file_name, source)
304
+
305
+ # pytest-style discovery
306
+ eval_pairs, discovered_config = self._collect_eval_functions(mod)
307
+ if eval_pairs:
308
+ file_config = discovered_config or (
309
+ mod.get_eval_config()
310
+ if hasattr(mod, "get_eval_config")
311
+ else mod.EVAL_CONFIG
312
+ if hasattr(mod, "EVAL_CONFIG")
313
+ else None
314
+ )
315
+ config, source = self._resolve_case_config(file_config, global_config)
316
+ pending: list[_PendingCase] = []
317
+ for _, es in eval_pairs:
318
+ pending.extend(self._make_pending(mod, es, config, file_name, source))
319
+ return pending
320
+
321
+ self.output.warning(f"Skipping {file_name} — no eval entry point found.")
322
+ return []
323
+
324
+ def _resolve_case_config(
325
+ self, file_config: EvalConfig | None, global_config: EvalConfig | None
326
+ ) -> tuple[EvalConfig, str]:
327
+ """Resolve the config for a file and label its source.
328
+
329
+ Priority (highest first): per-file get_eval_config()/EVAL_CONFIG, then the
330
+ global confeval.py, then the built-in defaults. The returned label is shown
331
+ in the criteria block so users can see which level each file resolved to.
332
+ """
333
+ if file_config is not None:
334
+ return file_config, "per-file"
335
+ if global_config is not None:
336
+ return global_config, "confeval.py"
337
+ return self._default_config(), "built-in defaults"
338
+
339
+ def _collect_simulations(
340
+ self, mod: Any, scenarios: list[Any], file_name: str
341
+ ) -> list[_PendingSimulation]:
342
+ """Build _PendingSimulation items for each scenario in the file."""
343
+ from tenxgraph.qa.evaluation import (
344
+ CriterionConfig,
345
+ SimulationGoalsCriterion,
346
+ UserSimulator,
347
+ UserSimulatorConfig,
348
+ )
349
+
350
+ graph = getattr(mod, "app", None) or self._load_agent_from_config()
351
+
352
+ # Per-file simulator config via SIMULATOR_CONFIG constant or default
353
+ sim_cfg: UserSimulatorConfig | None = getattr(mod, "SIMULATOR_CONFIG", None)
354
+ goal_threshold: float = 0.7
355
+ if sim_cfg is not None and hasattr(sim_cfg, "goal_threshold"):
356
+ goal_threshold = sim_cfg.goal_threshold # type: ignore[attr-defined]
357
+
358
+ judge = SimulationGoalsCriterion(
359
+ config=CriterionConfig(threshold=goal_threshold, num_samples=1)
360
+ )
361
+ simulator = UserSimulator(
362
+ config=sim_cfg or UserSimulatorConfig(),
363
+ criteria=[judge],
364
+ )
365
+
366
+ eval_set_id = f"{Path(file_name).stem}_simulations"
367
+ eval_set_name = f"{Path(file_name).stem} (user simulator)"
368
+
369
+ return [
370
+ _PendingSimulation(
371
+ scenario=sc,
372
+ graph=graph,
373
+ simulator=simulator,
374
+ file_name=file_name,
375
+ eval_set_id=eval_set_id,
376
+ eval_set_name=eval_set_name,
377
+ )
378
+ for sc in scenarios
379
+ ]
380
+
381
+ def _make_pending(
382
+ self,
383
+ mod: Any,
384
+ eval_set: Any,
385
+ config: EvalConfig,
386
+ file_name: str,
387
+ config_source: str = "",
388
+ ) -> list[_PendingCase]:
389
+ graph = getattr(mod, "app", None) or self._load_agent_from_config()
390
+ collector = TrajectoryCollector(capture_all_events=True)
391
+ evaluator = AgentEvaluator(graph, collector, config=config)
392
+ return [
393
+ _PendingCase(
394
+ case=c,
395
+ evaluator=evaluator,
396
+ config=config,
397
+ file_name=file_name,
398
+ eval_set_id=eval_set.eval_set_id,
399
+ eval_set_name=eval_set.name,
400
+ config_source=config_source,
401
+ )
402
+ for c in eval_set.eval_cases
403
+ ]
404
+
405
+ # ------------------------------------------------------------------
406
+ # Criteria display
407
+ # ------------------------------------------------------------------
408
+
409
+ def _print_criteria_block(
410
+ self,
411
+ confeval_config: EvalConfig | None,
412
+ confeval_path: Path | None,
413
+ ) -> None:
414
+ """Print the active global criteria and their source before any case runs.
415
+
416
+ This is the global fallback config (confeval.py, else built-in defaults)
417
+ applied to files without their own get_eval_config()/EVAL_CONFIG. Files
418
+ that define a per-file config override this for their own cases.
419
+ """
420
+ if confeval_config is not None:
421
+ source = str(confeval_path) if confeval_path else "confeval.py"
422
+ criteria = confeval_config.criteria
423
+ else:
424
+ source = "built-in defaults"
425
+ criteria = self._default_config().criteria
426
+
427
+ lines = [f"Criteria source: {source}"]
428
+ for name in criteria.model_fields:
429
+ cfg = getattr(criteria, name)
430
+ if cfg is None:
431
+ continue
432
+
433
+ parts = [f"threshold={cfg.threshold}"]
434
+ parts.append(cfg.match_type.value)
435
+ if cfg.judge_model:
436
+ parts.append(f"judge={cfg.judge_model}")
437
+ if cfg.num_samples and cfg.num_samples != 1:
438
+ parts.append(f"samples={cfg.num_samples}")
439
+ lines.append(f" {name:<40} {' '.join(parts)}")
440
+ self.output.info("\n".join(lines), emoji=False)
441
+
442
+ def _criteria_rows(self, config: EvalConfig) -> list[tuple[str, str, dict[str, Any]]]:
443
+ """Return (name, human_summary, machine_dict) for each active criterion."""
444
+ criteria = config.criteria
445
+ rows: list[tuple[str, str, dict[str, Any]]] = []
446
+ for name in type(criteria).model_fields:
447
+ cfg = getattr(criteria, name)
448
+ if cfg is None:
449
+ continue
450
+ match_type = getattr(getattr(cfg, "match_type", None), "value", None)
451
+ parts = [f"threshold={cfg.threshold}"]
452
+ detail: dict[str, Any] = {"threshold": cfg.threshold}
453
+ if match_type:
454
+ parts.append(match_type)
455
+ detail["match_type"] = match_type
456
+ if cfg.judge_model:
457
+ parts.append(f"judge={cfg.judge_model}")
458
+ detail["judge_model"] = cfg.judge_model
459
+ if cfg.num_samples and cfg.num_samples != 1:
460
+ parts.append(f"samples={cfg.num_samples}")
461
+ detail["num_samples"] = cfg.num_samples
462
+ rows.append((name, " ".join(parts), detail))
463
+ return rows
464
+
465
+ def _criteria_by_file(
466
+ self, pending: list[_PendingCase | _PendingSimulation]
467
+ ) -> dict[str, dict[str, Any]]:
468
+ """Map each file to its resolved criteria source and active criteria.
469
+
470
+ Used both for the per-file console block and the merged report metadata so
471
+ a single combined run still records which criteria applied to which file.
472
+ """
473
+ out: dict[str, dict[str, Any]] = {}
474
+ for pc in pending:
475
+ if pc.file_name in out:
476
+ continue
477
+ if isinstance(pc, _PendingCase):
478
+ rows = self._criteria_rows(pc.config)
479
+ out[pc.file_name] = {
480
+ "source": pc.config_source,
481
+ "criteria": {name: detail for name, _, detail in rows},
482
+ }
483
+ elif isinstance(pc, _PendingSimulation):
484
+ out[pc.file_name] = {"source": "user-simulator goals", "criteria": {}}
485
+ return out
486
+
487
+ def _print_criteria_per_file(
488
+ self,
489
+ pending: list[_PendingCase | _PendingSimulation],
490
+ confeval_path: Path | None,
491
+ ) -> None:
492
+ """Print the criteria each file resolved to, before any case runs.
493
+
494
+ Unlike the single global block, this shows per-file criteria so a combined
495
+ run makes clear that each file is evaluated against its own config
496
+ (per-file > confeval.py > built-in defaults).
497
+ """
498
+ seen: set[str] = set()
499
+ blocks: list[str] = []
500
+ for pc in pending:
501
+ if pc.file_name in seen:
502
+ continue
503
+ seen.add(pc.file_name)
504
+
505
+ if isinstance(pc, _PendingSimulation):
506
+ blocks.append(f"Criteria {pc.file_name} (source: user-simulator goals)")
507
+ continue
508
+
509
+ source = pc.config_source
510
+ if source == "confeval.py" and confeval_path:
511
+ source = str(confeval_path)
512
+ lines = [f"Criteria {pc.file_name} (source: {source})"]
513
+ rows = self._criteria_rows(pc.config)
514
+ if not rows:
515
+ lines.append(" (no active criteria)")
516
+ for name, summary, _ in rows:
517
+ lines.append(f" {name:<40} {summary}")
518
+ blocks.append("\n".join(lines))
519
+ if blocks:
520
+ self.output.info("\n\n".join(blocks), emoji=False)
521
+
522
+ # ------------------------------------------------------------------
523
+ # Progress printing
524
+ # ------------------------------------------------------------------
525
+
526
+ @staticmethod
527
+ def _case_status(result: EvalCaseResult) -> str:
528
+ if result.passed:
529
+ return "passed"
530
+ return "error" if result.is_error else "failed"
531
+
532
+ @staticmethod
533
+ def _case_detail(result: EvalCaseResult) -> str:
534
+ detail = f"{result.duration_seconds:.2f}s"
535
+ tok = getattr(result, "token_usage", None)
536
+ if tok and (tok.input_tokens or tok.output_tokens):
537
+ detail += f" in={tok.input_tokens} out={tok.output_tokens}"
538
+ return detail
539
+
540
+ # ------------------------------------------------------------------
541
+ # Flat pool execution — single asyncio event loop for all cases
542
+ # ------------------------------------------------------------------
543
+
544
+ async def _run_flat_pool(
545
+ self,
546
+ pending: list[_PendingCase | _PendingSimulation],
547
+ max_concurrency: int,
548
+ parallel: bool,
549
+ ) -> list[tuple[str, str, str, EvalCaseResult]]:
550
+ """Run all cases and simulations under a single event loop.
551
+
552
+ Returns list of (file_name, eval_set_id, eval_set_name, EvalCaseResult).
553
+ """
554
+ total = len(pending)
555
+
556
+ async def _run_case(pc: _PendingCase) -> tuple[str, str, str, EvalCaseResult]:
557
+ local_collector = TrajectoryCollector(
558
+ capture_all_events=pc.evaluator.collector.capture_all_events,
559
+ )
560
+ _, local_mgr = make_trajectory_callback(local_collector)
561
+ container = pc.evaluator.graph._state_graph._container
562
+ # The Inject[CallbackManager] proxy on Node.execute caches its resolved
563
+ # value independently of the container — reset it so it re-resolves
564
+ # inside the override context instead of returning the stale instance.
565
+ _reset_inject_proxy(CallbackManager)
566
+ with override_dependency(CallbackManager, local_mgr, container=container):
567
+ try:
568
+ result = await pc.evaluator._evaluate_case(
569
+ pc.case, collector_override=local_collector
570
+ )
571
+ except Exception as exc:
572
+ result = EvalCaseResult.failure(
573
+ eval_id=pc.case.eval_id,
574
+ error=str(exc),
575
+ name=pc.case.name,
576
+ )
577
+ return (pc.file_name, pc.eval_set_id, pc.eval_set_name, result)
578
+
579
+ async def _run_simulation(ps: _PendingSimulation) -> tuple[str, str, str, EvalCaseResult]:
580
+ from tenxgraph.qa.evaluation.eval_result import CriterionResult
581
+ from tenxgraph.qa.evaluation.token_usage import TokenUsage
582
+
583
+ try:
584
+ sim_result = await ps.simulator.run(ps.graph, ps.scenario)
585
+ # Use the full CriterionResult objects (with per-criterion token_usage)
586
+ # if available; fall back to rebuilding from scores dict.
587
+ criterion_results = sim_result.criterion_results or [
588
+ CriterionResult.success(
589
+ criterion=name,
590
+ score=score,
591
+ threshold=ps.simulator.criteria[0].threshold
592
+ if ps.simulator.criteria
593
+ else 0.7,
594
+ details=sim_result.criterion_details.get(name, {}),
595
+ )
596
+ for name, score in sim_result.criterion_scores.items()
597
+ ]
598
+ # If no criterion ran (no goals defined), fall back to completion flag
599
+ if not criterion_results:
600
+ score = 1.0 if sim_result.completed else 0.0
601
+ criterion_results = [
602
+ CriterionResult.success(
603
+ criterion="simulation_completed",
604
+ score=score,
605
+ threshold=0.5,
606
+ )
607
+ ]
608
+ conversation_text = "\n".join(
609
+ f"{m['role'].upper()}: {m['content']}" for m in sim_result.conversation
610
+ )
611
+ criteria_token_usage = sum(
612
+ (cr.token_usage for cr in criterion_results), TokenUsage()
613
+ )
614
+ total_token_usage = sim_result.simulator_token_usage + criteria_token_usage
615
+ result = EvalCaseResult.success(
616
+ eval_id=ps.scenario.scenario_id,
617
+ name=ps.scenario.description or ps.scenario.scenario_id,
618
+ criterion_results=criterion_results,
619
+ actual_response=conversation_text,
620
+ token_usage=total_token_usage,
621
+ agent_token_usage=sim_result.simulator_token_usage,
622
+ metadata={
623
+ "turns": sim_result.turns,
624
+ "goals_achieved": sim_result.goals_achieved,
625
+ "completed": sim_result.completed,
626
+ },
627
+ )
628
+ except Exception as exc:
629
+ result = EvalCaseResult.failure(
630
+ eval_id=ps.scenario.scenario_id,
631
+ error=str(exc),
632
+ name=ps.scenario.description or ps.scenario.scenario_id,
633
+ )
634
+ return (ps.file_name, ps.eval_set_id, ps.eval_set_name, result)
635
+
636
+ async def _dispatch(
637
+ item: _PendingCase | _PendingSimulation,
638
+ ) -> tuple[str, str, str, EvalCaseResult]:
639
+ if isinstance(item, _PendingSimulation):
640
+ return await _run_simulation(item)
641
+ return await _run_case(item)
642
+
643
+ def _record(progress: Any, quad: tuple[str, str, str, EvalCaseResult]) -> None:
644
+ file_name, _, _, result = quad
645
+ progress.record(
646
+ f"{file_name}::{result.name or result.eval_id}",
647
+ status=self._case_status(result),
648
+ detail=self._case_detail(result),
649
+ )
650
+
651
+ title = "Running evaluation cases" + (f" ({max_concurrency} at a time)" if parallel else "")
652
+
653
+ if not parallel:
654
+ results: list[tuple[str, str, str, EvalCaseResult]] = []
655
+ with self.output.progress_run(title, total=total) as progress:
656
+ for item in pending:
657
+ quad = await _dispatch(item)
658
+ _record(progress, quad)
659
+ results.append(quad)
660
+ return results
661
+
662
+ semaphore = asyncio.Semaphore(max_concurrency)
663
+
664
+ async def _run_one(
665
+ item: _PendingCase | _PendingSimulation,
666
+ ) -> tuple[str, str, str, EvalCaseResult]:
667
+ async with semaphore:
668
+ return await _dispatch(item)
669
+
670
+ output_results: list[tuple[str, str, str, EvalCaseResult]] = []
671
+ with self.output.progress_run(title, total=total) as progress:
672
+ tasks = [asyncio.create_task(_run_one(item)) for item in pending]
673
+ for coro in asyncio.as_completed(tasks):
674
+ quad = await coro
675
+ _record(progress, quad)
676
+ output_results.append(quad)
677
+
678
+ return output_results
679
+
680
+ # ------------------------------------------------------------------
681
+ # Report merging
682
+ # ------------------------------------------------------------------
683
+
684
+ def _merge_reports(self, reports: list[EvalReport], base_config: Any = None) -> EvalReport:
685
+ if len(reports) == 1:
686
+ return reports[0]
687
+
688
+ all_results = []
689
+ for r in reports:
690
+ all_results.extend(r.results)
691
+ return ER.create(
692
+ eval_set_id="combined_eval",
693
+ eval_set_name="Combined Evaluation",
694
+ results=all_results,
695
+ config_used=base_config.model_dump() if base_config else {},
696
+ )
697
+
698
+ # ------------------------------------------------------------------
699
+ # Eval directory from 10xgraph.json
700
+ # ------------------------------------------------------------------
701
+
702
+ def _resolve_eval_dir(self) -> Path:
703
+ config_manager = ConfigManager()
704
+ discovered = config_manager.auto_discover_config()
705
+ directory = "evals"
706
+ if discovered:
707
+ try:
708
+ config_manager.load_config(str(discovered))
709
+ eval_cfg = config_manager.get_evaluation_config()
710
+ directory = eval_cfg.get("directory", "evals")
711
+ except Exception:
712
+ self.logger.warning(
713
+ "Failed to load eval directory from config; using default 'evals/'"
714
+ )
715
+ return Path.cwd() / directory
716
+
717
+ # ------------------------------------------------------------------
718
+ # Main entry point
719
+ # ------------------------------------------------------------------
720
+
721
+ def execute( # noqa: PLR0912, PLR0915
722
+ self,
723
+ target: str | None = None,
724
+ output_dir: str = "eval_reports",
725
+ no_report: bool = False,
726
+ threshold: float | None = None,
727
+ open_report: bool = False,
728
+ parallel: bool = False,
729
+ max_concurrency: int = 4,
730
+ verbose: bool = False,
731
+ quiet: bool = False,
732
+ **kwargs: Any,
733
+ ) -> int:
734
+ # 1. Load infra settings from 10xgraph.json — no criteria here.
735
+ # Criteria come exclusively from confeval.py (step 4) or per-file config.
736
+ config_manager = ConfigManager()
737
+ discovered = config_manager.auto_discover_config()
738
+ json_directory = "evals"
739
+ effective_parallel = parallel
740
+ effective_concurrency = max_concurrency
741
+ if discovered:
742
+ try:
743
+ config_manager.load_config(str(discovered))
744
+ global_eval_cfg = config_manager.get_evaluation_config()
745
+ if output_dir == "eval_reports":
746
+ output_dir = global_eval_cfg.get("output_dir", output_dir)
747
+ if threshold is None:
748
+ threshold = global_eval_cfg.get("threshold")
749
+ if not parallel:
750
+ effective_parallel = global_eval_cfg.get("parallel", False)
751
+ if max_concurrency == DEFAULT_CONCURRENCY:
752
+ effective_concurrency = global_eval_cfg.get(
753
+ "max_concurrency", DEFAULT_CONCURRENCY
754
+ )
755
+ json_directory = global_eval_cfg.get("directory", json_directory)
756
+ except Exception:
757
+ self.logger.warning("Failed to load eval config from 10xgraph.json; using defaults")
758
+
759
+ # 2. CLI flags already captured as function parameters (highest priority).
760
+
761
+ # 3. Resolve target path
762
+ if target:
763
+ target_path = Path(target)
764
+ if not target_path.exists():
765
+ self.output.error(f"Path not found: {target}")
766
+ return 1
767
+ else:
768
+ target_path = Path.cwd() / json_directory
769
+ if not target_path.exists():
770
+ self.output.error(
771
+ f"Eval directory '{target_path}' not found. "
772
+ "Create an evals/ directory or pass a file/folder path."
773
+ )
774
+ return 1
775
+
776
+ # 4. Load confeval.py → global criteria config (None if not found).
777
+ # confeval.py is the GLOBAL config: discover it independently of the
778
+ # target so it applies whether the user evaluates the whole evals/
779
+ # directory, a subfolder, or a single file inside it.
780
+ evals_dir = Path.cwd() / json_directory
781
+ confeval_config, confeval_path = self._resolve_confeval(target_path, evals_dir)
782
+
783
+ # 5. Discover files
784
+ files = self._discover(target_path)
785
+ if not files:
786
+ self.output.error(f"No eval files found in {target_path}")
787
+ return 1
788
+
789
+ # 6. Collect all pending cases across every file
790
+ pending: list[_PendingCase | _PendingSimulation] = []
791
+ for f in files:
792
+ try:
793
+ cases = self._collect_from_file(f, confeval_config)
794
+ pending.extend(cases)
795
+ except Exception as exc:
796
+ self.output.error(f"Error loading {f.name}: {exc}")
797
+ self.logger.exception("Failed to load eval file: %s", f)
798
+
799
+ if not pending:
800
+ self.output.error(
801
+ "No eval cases found. Ensure eval files expose get_eval_set() "
802
+ "or functions annotated with -> EvalSet."
803
+ )
804
+ return 1
805
+
806
+ n_files = len({pc.file_name for pc in pending})
807
+ n_sims = sum(1 for pc in pending if isinstance(pc, _PendingSimulation))
808
+ n_cases = len(pending) - n_sims
809
+ parts = []
810
+ if n_cases:
811
+ parts.append(f"{n_cases} eval case(s)")
812
+ if n_sims:
813
+ parts.append(f"{n_sims} simulation scenario(s)")
814
+ self.output.command_header(
815
+ "eval",
816
+ f"Found {', '.join(parts)} across {n_files} file(s) in {target_path}",
817
+ )
818
+
819
+ # 7. Print per-file criteria so users see which config each file resolved to
820
+ self._print_criteria_per_file(pending, confeval_path)
821
+
822
+ # 8. Run all cases under a single asyncio event loop
823
+ quads = asyncio.run(self._run_flat_pool(pending, effective_concurrency, effective_parallel))
824
+
825
+ if not quads:
826
+ self.output.error("No results produced.")
827
+ return 1
828
+
829
+ # Build per-eval-set config map from pending before results are consumed
830
+ group_configs: dict[str, Any] = {
831
+ pc.eval_set_id: pc.config for pc in pending if isinstance(pc, _PendingCase)
832
+ }
833
+
834
+ # 7. Group by eval_set_id → one EvalReport per set
835
+ groups: dict[str, tuple[str, list[EvalCaseResult]]] = defaultdict(lambda: ("", []))
836
+ for _, eval_set_id, eval_set_name, result in quads:
837
+ name, results_list = groups[eval_set_id]
838
+ groups[eval_set_id] = (eval_set_name or name, [*results_list, result])
839
+
840
+ reports: list[EvalReport] = []
841
+ for eval_set_id, (eval_set_name, results) in groups.items():
842
+ group_cfg = group_configs.get(eval_set_id) or confeval_config or self._default_config()
843
+ reports.append(
844
+ ER.create(
845
+ eval_set_id=eval_set_id,
846
+ eval_set_name=eval_set_name,
847
+ results=results,
848
+ config_used=group_cfg.model_dump(),
849
+ )
850
+ )
851
+
852
+ # 8. Merge into a single report
853
+ merged = self._merge_reports(reports, base_config=confeval_config or self._default_config())
854
+
855
+ # Record per-file criteria in the merged report so a single combined run still
856
+ # captures which criteria applied to which file (the merged config_used alone
857
+ # collapses to one config and loses this).
858
+ if isinstance(getattr(merged, "metadata", None), dict):
859
+ merged.metadata["criteria_by_file"] = self._criteria_by_file(pending)
860
+
861
+ # 9. Determine exit code
862
+ if threshold is not None and merged.summary.pass_rate < threshold:
863
+ self.output.error(
864
+ f"Pass rate {merged.summary.pass_rate:.1%} is below threshold {threshold:.1%}"
865
+ )
866
+ return_code = 1
867
+ else:
868
+ return_code = 0 if merged.summary.pass_rate == 1.0 else 1
869
+
870
+ # 10. Generate reports
871
+ if not no_report:
872
+ manager = ReporterManager(
873
+ ReporterConfig(
874
+ output_dir=output_dir,
875
+ html=True,
876
+ json_report=True,
877
+ console=False,
878
+ timestamp_files=True,
879
+ )
880
+ )
881
+ report_result = manager.run_all(merged)
882
+
883
+ if report_result.html_path:
884
+ self.output.success(f"HTML report: {report_result.html_path}")
885
+ if report_result.json_path:
886
+ self.output.info(f"JSON report: {report_result.json_path}", emoji=False)
887
+ if report_result.has_errors:
888
+ for name, err in report_result.errors:
889
+ self.output.warning(f"Reporter error [{name}]: {err}")
890
+
891
+ if open_report and report_result.html_path:
892
+ webbrowser.open(Path(report_result.html_path).resolve().as_uri())
893
+
894
+ summary = merged.summary
895
+ self.output.info(
896
+ f"Results: {summary.passed_cases}/{summary.total_cases} passed "
897
+ f"({summary.pass_rate:.1%})",
898
+ emoji=False,
899
+ )
900
+ tok = summary.total_token_usage
901
+ if tok.total_tokens:
902
+ cache_part = f" cache_read: {tok.cache_read_tokens:,}" if tok.cache_read_tokens else ""
903
+ self.output.info(
904
+ f"Tokens — input: {tok.input_tokens:,} output: {tok.output_tokens:,}"
905
+ f"{cache_part} total: {tok.total_tokens:,}",
906
+ emoji=False,
907
+ )
908
+
909
+ return return_code