continuous-intelligence-layer 0.1.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.
- continuous_intelligence_layer/__init__.py +28 -0
- continuous_intelligence_layer/_core/__init__.py +6 -0
- continuous_intelligence_layer/_core/exporter.py +517 -0
- continuous_intelligence_layer/_core/graph_exporter.py +92 -0
- continuous_intelligence_layer/_core/utils.py +170 -0
- continuous_intelligence_layer/anthropic/__init__.py +9 -0
- continuous_intelligence_layer/anthropic/init.py +232 -0
- continuous_intelligence_layer/anthropic/instrumentation.py +91 -0
- continuous_intelligence_layer/crewai/__init__.py +9 -0
- continuous_intelligence_layer/crewai/init.py +228 -0
- continuous_intelligence_layer/crewai/instrumentation.py +83 -0
- continuous_intelligence_layer/langgraph/__init__.py +12 -0
- continuous_intelligence_layer/langgraph/init.py +253 -0
- continuous_intelligence_layer/langgraph/instrumentation.py +71 -0
- continuous_intelligence_layer/openai/__init__.py +9 -0
- continuous_intelligence_layer/openai/init.py +229 -0
- continuous_intelligence_layer/openai/instrumentation.py +67 -0
- continuous_intelligence_layer-0.1.0.dist-info/METADATA +633 -0
- continuous_intelligence_layer-0.1.0.dist-info/RECORD +38 -0
- continuous_intelligence_layer-0.1.0.dist-info/WHEEL +4 -0
- continuous_intelligence_layer-0.1.0.dist-info/licenses/LICENSE +21 -0
- evaluators/__init__.py +32 -0
- evaluators/base_evaluator.py +181 -0
- evaluators/crewai_input_evaluator.py +249 -0
- evaluators/input_evaluator.py +121 -0
- evaluators/models.py +180 -0
- evaluators/output_evaluator.py +247 -0
- evaluators/runner.py +313 -0
- evaluators/tool_agent_evaluator.py +305 -0
- graph_builder/__init__.py +6 -0
- graph_builder/builder.py +212 -0
- graph_builder/models.py +159 -0
- graph_builder/mongo_store.py +588 -0
- llm_router/__init__.py +3 -0
- llm_router/router.py +71 -0
- rca_engine/__init__.py +5 -0
- rca_engine/incident_report.py +162 -0
- rca_engine/rca_engine.py +202 -0
evaluators/runner.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""
|
|
2
|
+
runner.py
|
|
3
|
+
---------
|
|
4
|
+
EvaluationRunner — Orchestrates all 3 evaluators for a given execution.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from evaluators.runner import EvaluationRunner
|
|
8
|
+
|
|
9
|
+
report = EvaluationRunner(
|
|
10
|
+
execution_id="abc123",
|
|
11
|
+
llm_config={"provider": "openai", "model": "gpt-5.6-luna", "api_key": "..."},
|
|
12
|
+
).run()
|
|
13
|
+
print(report.model_dump_json(indent=2))
|
|
14
|
+
|
|
15
|
+
What it does:
|
|
16
|
+
1. Loads the ExecutionGraph from MongoDB.
|
|
17
|
+
2. Runs InputEvaluator, OutputEvaluator, ToolAgentEvaluator on every node.
|
|
18
|
+
3. Writes EvaluationResults to MongoDB (`evaluations` docs + flat props on the node document).
|
|
19
|
+
4. Appends all results to evaluation_results.jsonl.
|
|
20
|
+
5. Triggers the RCA Engine and returns the full RCAReport.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _safe_json_loads(s: str) -> dict:
|
|
32
|
+
"""Parse JSON string, returning {} on any error (handles truncated blobs)."""
|
|
33
|
+
try:
|
|
34
|
+
return json.loads(s)
|
|
35
|
+
except Exception:
|
|
36
|
+
return {}
|
|
37
|
+
|
|
38
|
+
from graph_builder.models import ExecutionGraph, ExecutionNode
|
|
39
|
+
from graph_builder.mongo_store import MongoStore
|
|
40
|
+
from graph_builder.builder import TraceToGraphBuilder
|
|
41
|
+
|
|
42
|
+
from .input_evaluator import InputEvaluator
|
|
43
|
+
from .crewai_input_evaluator import CrewAIInputEvaluator
|
|
44
|
+
from .output_evaluator import OutputEvaluator
|
|
45
|
+
from .tool_agent_evaluator import ToolAgentEvaluator
|
|
46
|
+
from .models import EvaluationResult, NodeEvaluationSummary
|
|
47
|
+
from llm_router import LLMRouter
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class EvaluationRunner:
|
|
51
|
+
"""
|
|
52
|
+
Runs all 3 evaluators against every node of a given execution and
|
|
53
|
+
persists results to MongoDB + JSONL.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
execution_id: str,
|
|
59
|
+
project_id: str | None = None,
|
|
60
|
+
output_path: str | None = None,
|
|
61
|
+
verbose: bool = True,
|
|
62
|
+
llm_config: dict | None = None,
|
|
63
|
+
):
|
|
64
|
+
self.execution_id = execution_id
|
|
65
|
+
self.project_id = project_id
|
|
66
|
+
self.output_path = Path(
|
|
67
|
+
output_path or os.getenv("EVALUATION_OUTPUT_PATH", "./evaluation_results.jsonl")
|
|
68
|
+
)
|
|
69
|
+
self.verbose = verbose
|
|
70
|
+
self.llm_config = llm_config
|
|
71
|
+
|
|
72
|
+
# Storage
|
|
73
|
+
self._store = MongoStore()
|
|
74
|
+
|
|
75
|
+
def run(self) -> dict:
|
|
76
|
+
"""
|
|
77
|
+
Run the full evaluation pipeline for this execution_id.
|
|
78
|
+
Returns a summary dict with all results + RCA report.
|
|
79
|
+
"""
|
|
80
|
+
self._store.set_eval_status(self.execution_id, "running")
|
|
81
|
+
try:
|
|
82
|
+
if not self.llm_config:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"No evaluation API key available for execution_id="
|
|
85
|
+
f"'{self.execution_id}'. Pass evaluation_api_key to init(), or "
|
|
86
|
+
f"supply a provider/model/api_key when triggering evaluations."
|
|
87
|
+
)
|
|
88
|
+
router = LLMRouter(**self.llm_config)
|
|
89
|
+
self._input_ev = InputEvaluator(router)
|
|
90
|
+
self._crewai_input_ev = CrewAIInputEvaluator(router)
|
|
91
|
+
self._output_ev = OutputEvaluator(router)
|
|
92
|
+
self._tool_ev = ToolAgentEvaluator(router)
|
|
93
|
+
|
|
94
|
+
# ── 1. Load graph ────────────────────────────────────────────
|
|
95
|
+
graph = self._load_graph()
|
|
96
|
+
if not graph or not graph.nodes:
|
|
97
|
+
raise ValueError(f"No graph found in MongoDB for execution_id='{self.execution_id}'")
|
|
98
|
+
|
|
99
|
+
if self.verbose:
|
|
100
|
+
print(f"\n[EvaluationRunner] ▶ Starting evaluation for execution: {self.execution_id}")
|
|
101
|
+
print(f"[EvaluationRunner] Nodes to evaluate: {len(graph.nodes)}")
|
|
102
|
+
|
|
103
|
+
# ── 2. Run evaluators on every node ──────────────────────────
|
|
104
|
+
all_results: list[EvaluationResult] = []
|
|
105
|
+
summaries: list[NodeEvaluationSummary] = []
|
|
106
|
+
|
|
107
|
+
for i, node in enumerate(graph.nodes, 1):
|
|
108
|
+
if self.verbose:
|
|
109
|
+
print(f"\n[EvaluationRunner] [{i}/{len(graph.nodes)}] Evaluating: "
|
|
110
|
+
f"{node.name} ({node.node_type})")
|
|
111
|
+
|
|
112
|
+
# CrewAI's Task._execute_core / lifecycle nodes never carry
|
|
113
|
+
# the resolved task text in their own input by design (see
|
|
114
|
+
# evaluators/crewai_input_evaluator.py) — for those specific
|
|
115
|
+
# node shapes, the CrewAI-specific evaluator fills the
|
|
116
|
+
# input_result slot instead of the generic InputEvaluator.
|
|
117
|
+
if self._crewai_input_ev.should_run(node, graph):
|
|
118
|
+
input_res = self._crewai_input_ev.evaluate(node, graph)
|
|
119
|
+
else:
|
|
120
|
+
input_res = self._input_ev.evaluate(node, graph)
|
|
121
|
+
output_res = self._output_ev.evaluate(node, graph)
|
|
122
|
+
tool_res = self._tool_ev.evaluate(node, graph)
|
|
123
|
+
|
|
124
|
+
if self.verbose:
|
|
125
|
+
_print_result(" Input ", input_res)
|
|
126
|
+
_print_result(" Output ", output_res)
|
|
127
|
+
_print_result(" Tool ", tool_res)
|
|
128
|
+
|
|
129
|
+
all_results.extend([input_res, output_res, tool_res])
|
|
130
|
+
|
|
131
|
+
summary = NodeEvaluationSummary(
|
|
132
|
+
node_id=node.node_id,
|
|
133
|
+
node_name=node.name,
|
|
134
|
+
execution_id=self.execution_id,
|
|
135
|
+
node_type=node.node_type,
|
|
136
|
+
input_result=input_res,
|
|
137
|
+
output_result=output_res,
|
|
138
|
+
tool_agent_result=tool_res,
|
|
139
|
+
)
|
|
140
|
+
summaries.append(summary)
|
|
141
|
+
|
|
142
|
+
# ── 3. Persist to MongoDB ────────────────────────────────
|
|
143
|
+
self._persist_to_mongo(node, summary, [input_res, output_res, tool_res])
|
|
144
|
+
|
|
145
|
+
# ── 4. Write to JSONL ────────────────────────────────────────
|
|
146
|
+
self._write_jsonl(all_results)
|
|
147
|
+
|
|
148
|
+
# ── 5. Run RCA Engine ────────────────────────────────────────
|
|
149
|
+
rca_report = self._run_rca(graph, summaries, router)
|
|
150
|
+
self._store.write_rca_result(rca_report)
|
|
151
|
+
|
|
152
|
+
# ── 6. Build summary ─────────────────────────────────────────
|
|
153
|
+
fail_count = sum(1 for s in summaries if s.overall_status == "FAIL")
|
|
154
|
+
warn_count = sum(1 for s in summaries if s.overall_status == "WARNING")
|
|
155
|
+
|
|
156
|
+
if self.verbose:
|
|
157
|
+
print(f"\n[EvaluationRunner] ✅ Evaluation complete")
|
|
158
|
+
print(f" Nodes: {len(graph.nodes)} | FAIL: {fail_count} | WARNING: {warn_count}")
|
|
159
|
+
print(f" Results saved to: {self.output_path}")
|
|
160
|
+
print(f" RCA Overall: {rca_report.get('overall_status', 'N/A')}")
|
|
161
|
+
|
|
162
|
+
self._store.set_eval_status(self.execution_id, "done")
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
"execution_id": self.execution_id,
|
|
166
|
+
"nodes_evaluated": len(graph.nodes),
|
|
167
|
+
"nodes_failed": fail_count,
|
|
168
|
+
"nodes_warned": warn_count,
|
|
169
|
+
"evaluation_results": [r.to_dict() for r in all_results],
|
|
170
|
+
"rca_report": rca_report,
|
|
171
|
+
}
|
|
172
|
+
except Exception as exc:
|
|
173
|
+
self._store.set_eval_status(self.execution_id, "error", error=str(exc)[:2000])
|
|
174
|
+
raise
|
|
175
|
+
finally:
|
|
176
|
+
self._store.close()
|
|
177
|
+
|
|
178
|
+
# ── Private helpers ─────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
def _load_graph(self) -> ExecutionGraph:
|
|
181
|
+
"""
|
|
182
|
+
Load spans from MongoDB and reconstruct an ExecutionGraph.
|
|
183
|
+
We load the raw node data and rebuild node objects.
|
|
184
|
+
"""
|
|
185
|
+
raw = self._store.get_graph(self.execution_id, project_id=self.project_id)
|
|
186
|
+
raw_nodes = raw.get("nodes", [])
|
|
187
|
+
raw_edges = raw.get("edges", [])
|
|
188
|
+
|
|
189
|
+
if not raw_nodes:
|
|
190
|
+
return ExecutionGraph(execution_id=self.execution_id)
|
|
191
|
+
|
|
192
|
+
# Reconstruct ExecutionNode objects from MongoDB documents
|
|
193
|
+
from graph_builder.models import ExecutionNode, ExecutionEdge, EdgeType, TokenUsage, NodeType
|
|
194
|
+
|
|
195
|
+
nodes = []
|
|
196
|
+
for rn in raw_nodes:
|
|
197
|
+
props = rn
|
|
198
|
+
tokens = TokenUsage(
|
|
199
|
+
prompt=props.get("tokens_prompt"),
|
|
200
|
+
completion=props.get("tokens_completion"),
|
|
201
|
+
total=props.get("tokens_total"),
|
|
202
|
+
)
|
|
203
|
+
node = ExecutionNode(
|
|
204
|
+
node_id=props.get("node_id") or props.get("_id", ""),
|
|
205
|
+
parent_id=props.get("parent_id"),
|
|
206
|
+
execution_id=props.get("execution_id", self.execution_id),
|
|
207
|
+
session_id=props.get("session_id"),
|
|
208
|
+
project_id=props.get("project_id"),
|
|
209
|
+
trace_id=props.get("trace_id", ""),
|
|
210
|
+
node_type=props.get("node_type", "Unknown"),
|
|
211
|
+
name=props.get("name", "unnamed"),
|
|
212
|
+
model=props.get("model"),
|
|
213
|
+
tool_name=props.get("tool_name"),
|
|
214
|
+
tool_desc=props.get("tool_desc"),
|
|
215
|
+
retrieved_docs=props.get("retrieved_docs"),
|
|
216
|
+
available_tools=props.get("available_tools"),
|
|
217
|
+
tool_calls=props.get("tool_calls"),
|
|
218
|
+
input=props.get("input"),
|
|
219
|
+
output=props.get("output"),
|
|
220
|
+
prompt=props.get("prompt"),
|
|
221
|
+
response=props.get("response"),
|
|
222
|
+
latency_ms=props.get("latency_ms", 0.0),
|
|
223
|
+
tokens=tokens,
|
|
224
|
+
error=props.get("error"),
|
|
225
|
+
status=props.get("status", "UNSET"),
|
|
226
|
+
timestamp=props.get("timestamp"),
|
|
227
|
+
end_timestamp=props.get("end_timestamp"),
|
|
228
|
+
metadata=_safe_json_loads(props.get("metadata") or "{}"),
|
|
229
|
+
)
|
|
230
|
+
nodes.append(node)
|
|
231
|
+
|
|
232
|
+
edges = []
|
|
233
|
+
for re_ in raw_edges:
|
|
234
|
+
try:
|
|
235
|
+
edges.append(ExecutionEdge(
|
|
236
|
+
source_id=re_["src"],
|
|
237
|
+
target_id=re_["tgt"],
|
|
238
|
+
edge_type=re_.get("rel", "CALLS"),
|
|
239
|
+
))
|
|
240
|
+
except Exception:
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
session_id = nodes[0].session_id if nodes else None
|
|
244
|
+
graph = ExecutionGraph(
|
|
245
|
+
execution_id=self.execution_id,
|
|
246
|
+
session_id=session_id,
|
|
247
|
+
project_id=self.project_id or (nodes[0].project_id if nodes else None),
|
|
248
|
+
nodes=nodes,
|
|
249
|
+
edges=edges,
|
|
250
|
+
)
|
|
251
|
+
return graph
|
|
252
|
+
|
|
253
|
+
def _persist_to_mongo(
|
|
254
|
+
self,
|
|
255
|
+
node: ExecutionNode,
|
|
256
|
+
summary: NodeEvaluationSummary,
|
|
257
|
+
results: list[EvaluationResult],
|
|
258
|
+
) -> None:
|
|
259
|
+
"""Write evaluation results to MongoDB."""
|
|
260
|
+
try:
|
|
261
|
+
# Write each EvaluationResult as a separate `evaluations` document
|
|
262
|
+
for result in results:
|
|
263
|
+
if result.status == "SKIP":
|
|
264
|
+
continue
|
|
265
|
+
self._store.write_evaluation_result(result, project_id=node.project_id)
|
|
266
|
+
|
|
267
|
+
# Write flat boolean props + overall status to the node document
|
|
268
|
+
flat_props = summary.flat_eval_props()
|
|
269
|
+
all_results_json = [r.to_dict() for r in results]
|
|
270
|
+
self._store.update_node_evaluation(
|
|
271
|
+
node_id=node.node_id,
|
|
272
|
+
flat_props=flat_props,
|
|
273
|
+
all_results=all_results_json,
|
|
274
|
+
)
|
|
275
|
+
except Exception as exc:
|
|
276
|
+
print(f"[EvaluationRunner] ⚠ MongoDB write failed for {node.name}: {exc}")
|
|
277
|
+
|
|
278
|
+
def _write_jsonl(self, results: list[EvaluationResult]) -> None:
|
|
279
|
+
"""Append all evaluation results to JSONL file."""
|
|
280
|
+
self.output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
281
|
+
with self.output_path.open("a", encoding="utf-8") as f:
|
|
282
|
+
for result in results:
|
|
283
|
+
f.write(json.dumps(result.to_dict(), default=str) + "\n")
|
|
284
|
+
|
|
285
|
+
def _run_rca(self, graph: ExecutionGraph, summaries: list[NodeEvaluationSummary], router: LLMRouter) -> dict:
|
|
286
|
+
"""Trigger the RCA Engine and return its report as a dict."""
|
|
287
|
+
try:
|
|
288
|
+
from rca_engine.rca_engine import RCAEngine
|
|
289
|
+
# Attach evaluation results to graph nodes so RCA can use them
|
|
290
|
+
for summary in summaries:
|
|
291
|
+
node = graph.get_node(summary.node_id)
|
|
292
|
+
if node:
|
|
293
|
+
node.validation_results = [r.to_dict() for r in summary.all_results()]
|
|
294
|
+
|
|
295
|
+
engine = RCAEngine(router)
|
|
296
|
+
rca_result = engine.analyze(graph)
|
|
297
|
+
return rca_result.model_dump()
|
|
298
|
+
except Exception as exc:
|
|
299
|
+
print(f"[EvaluationRunner] ⚠ RCA Engine failed: {exc}")
|
|
300
|
+
return {"error": str(exc), "overall_status": "UNKNOWN"}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ── Pretty printer ──────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
_STATUS_ICONS = {"PASS": "✅", "FAIL": "❌", "WARNING": "⚠️ ", "SKIP": "⏭️ "}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _print_result(label: str, result: EvaluationResult) -> None:
|
|
309
|
+
icon = _STATUS_ICONS.get(result.status, "?")
|
|
310
|
+
sev = f" [{result.severity}]" if result.severity else ""
|
|
311
|
+
print(f" {label}: {icon} {result.status}{sev} (conf={result.confidence:.2f})")
|
|
312
|
+
if result.status not in ("PASS", "SKIP"):
|
|
313
|
+
print(f" → {result.reason[:120]}")
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tool_agent_evaluator.py
|
|
3
|
+
-----------------------
|
|
4
|
+
Evaluator 3 — Tool / Agent Evaluator
|
|
5
|
+
|
|
6
|
+
Validates agent nodes that call tools or sub-agents.
|
|
7
|
+
Runs on: Agent nodes AND Tool/Retriever nodes.
|
|
8
|
+
|
|
9
|
+
For AGENT nodes (that have tool/agent children):
|
|
10
|
+
Evaluates tool selection — was the right tool chosen?
|
|
11
|
+
|
|
12
|
+
For TOOL / RETRIEVER nodes:
|
|
13
|
+
Walks up to find the parent agent to understand why the tool was called,
|
|
14
|
+
then validates:
|
|
15
|
+
- Input passed to the tool: was it correct and complete?
|
|
16
|
+
- Output returned from the tool: was it relevant and usable?
|
|
17
|
+
|
|
18
|
+
checks schema:
|
|
19
|
+
{
|
|
20
|
+
"tool_selection": {
|
|
21
|
+
"verdict": "PASS" | "FAIL" | "WARNING" | "N/A",
|
|
22
|
+
"is_right_tool": bool,
|
|
23
|
+
"is_necessary": bool,
|
|
24
|
+
"alternatives": [str],
|
|
25
|
+
"reason": str
|
|
26
|
+
},
|
|
27
|
+
"input_to_tool": {
|
|
28
|
+
"verdict": "PASS" | "FAIL" | "WARNING" | "N/A",
|
|
29
|
+
"is_correct": bool,
|
|
30
|
+
"is_complete": bool,
|
|
31
|
+
"issues": [str]
|
|
32
|
+
},
|
|
33
|
+
"output_from_tool": {
|
|
34
|
+
"verdict": "PASS" | "FAIL" | "WARNING" | "N/A",
|
|
35
|
+
"is_relevant": bool,
|
|
36
|
+
"is_correct": bool,
|
|
37
|
+
"issues": [str]
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
from graph_builder.models import ExecutionGraph, ExecutionNode, NodeType
|
|
45
|
+
from .base_evaluator import BaseEvaluator
|
|
46
|
+
from .models import EvaluationResult, EvaluationStatus, Severity
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_PROMPT_AGENT = """\
|
|
50
|
+
You are an AI agent execution auditor. Evaluate an AGENT node that called one or more tools or sub-agents.
|
|
51
|
+
|
|
52
|
+
## Agent Node
|
|
53
|
+
{agent_context}
|
|
54
|
+
|
|
55
|
+
## Tools / Sub-Agents Called
|
|
56
|
+
{tools_called}
|
|
57
|
+
|
|
58
|
+
## Instructions
|
|
59
|
+
Evaluate whether the agent made GOOD TOOL SELECTION decisions.
|
|
60
|
+
|
|
61
|
+
For each tool that was called, determine:
|
|
62
|
+
1. **Is it the right tool?** — Given the task, was this the most appropriate tool to call?
|
|
63
|
+
2. **Was it necessary?** — Was calling this tool required, or could the agent have answered without it?
|
|
64
|
+
3. **Were there better alternatives?** — What other tools/agents could have been selected instead?
|
|
65
|
+
|
|
66
|
+
## Severity Guide
|
|
67
|
+
- CRITICAL: Tool selection will definitely cause wrong/harmful outcomes
|
|
68
|
+
- HIGH: Clearly wrong tool chosen when a better one was available
|
|
69
|
+
- MEDIUM: Tool works but a better choice existed
|
|
70
|
+
- LOW: Tool choice is acceptable but slightly suboptimal
|
|
71
|
+
|
|
72
|
+
## Response Format
|
|
73
|
+
Respond ONLY with a valid JSON object. No explanation outside the JSON.
|
|
74
|
+
|
|
75
|
+
{{
|
|
76
|
+
"status": "PASS" | "FAIL" | "WARNING",
|
|
77
|
+
"severity": "LOW" | "MEDIUM" | "HIGH" | "CRITICAL" | null,
|
|
78
|
+
"confidence": <float 0.0–1.0>,
|
|
79
|
+
"reason": "<1-2 sentence explanation>",
|
|
80
|
+
"suggestion": "<specific fix or null if PASS>",
|
|
81
|
+
"checks": {{
|
|
82
|
+
"tool_selection": {{
|
|
83
|
+
"verdict": "PASS" | "FAIL" | "WARNING",
|
|
84
|
+
"is_right_tool": true | false,
|
|
85
|
+
"is_necessary": true | false,
|
|
86
|
+
"alternatives": ["<better_tool_1>"],
|
|
87
|
+
"reason": "<explanation>"
|
|
88
|
+
}},
|
|
89
|
+
"input_to_tool": {{ "verdict": "N/A", "is_correct": null, "is_complete": null, "issues": [] }},
|
|
90
|
+
"output_from_tool": {{ "verdict": "N/A", "is_relevant": null, "is_correct": null, "issues": [] }}
|
|
91
|
+
}}
|
|
92
|
+
}}
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
_PROMPT_TOOL = """\
|
|
96
|
+
You are an AI agent execution auditor. Evaluate a TOOL/RETRIEVER node execution.
|
|
97
|
+
|
|
98
|
+
## Parent Agent Context (why this tool was called)
|
|
99
|
+
{agent_context}
|
|
100
|
+
|
|
101
|
+
## Tool Node Being Evaluated
|
|
102
|
+
Name: {tool_name}
|
|
103
|
+
Description: {tool_desc}
|
|
104
|
+
Input sent to tool: {tool_input}
|
|
105
|
+
Output from tool: {tool_output}
|
|
106
|
+
Status: {tool_status}
|
|
107
|
+
Error: {tool_error}
|
|
108
|
+
|
|
109
|
+
## Instructions
|
|
110
|
+
Evaluate THREE things:
|
|
111
|
+
|
|
112
|
+
1. **Input Quality** — Was the data sent TO this tool correct and complete?
|
|
113
|
+
- Were required parameters present and well-formed?
|
|
114
|
+
- Was the data accurate and relevant to the task?
|
|
115
|
+
|
|
116
|
+
2. **Output Quality** — Was the result FROM this tool relevant and correct?
|
|
117
|
+
- Did the tool return useful data for the task?
|
|
118
|
+
- Is the output complete, or is it truncated/empty/errored?
|
|
119
|
+
|
|
120
|
+
3. **Tool Selection** — Given the agent's goal, was this the right tool to call?
|
|
121
|
+
(You already have the parent agent context to judge this)
|
|
122
|
+
|
|
123
|
+
## Severity Guide
|
|
124
|
+
- CRITICAL: Tool returned harmful data, or catastrophic input caused data corruption/security issue
|
|
125
|
+
- HIGH: Input was completely wrong causing tool failure, or output is entirely irrelevant
|
|
126
|
+
- MEDIUM: Partial input issues or partially relevant output
|
|
127
|
+
- LOW: Minor quality issues
|
|
128
|
+
|
|
129
|
+
## Response Format
|
|
130
|
+
Respond ONLY with a valid JSON object. No explanation outside the JSON.
|
|
131
|
+
|
|
132
|
+
{{
|
|
133
|
+
"status": "PASS" | "FAIL" | "WARNING",
|
|
134
|
+
"severity": "LOW" | "MEDIUM" | "HIGH" | "CRITICAL" | null,
|
|
135
|
+
"confidence": <float 0.0–1.0>,
|
|
136
|
+
"reason": "<1-2 sentence explanation>",
|
|
137
|
+
"suggestion": "<specific fix or null if PASS>",
|
|
138
|
+
"checks": {{
|
|
139
|
+
"tool_selection": {{
|
|
140
|
+
"verdict": "PASS" | "FAIL" | "WARNING",
|
|
141
|
+
"is_right_tool": true | false,
|
|
142
|
+
"is_necessary": true | false,
|
|
143
|
+
"alternatives": [],
|
|
144
|
+
"reason": "<explanation>"
|
|
145
|
+
}},
|
|
146
|
+
"input_to_tool": {{
|
|
147
|
+
"verdict": "PASS" | "FAIL" | "WARNING",
|
|
148
|
+
"is_correct": true | false,
|
|
149
|
+
"is_complete": true | false,
|
|
150
|
+
"issues": ["<issue1>"]
|
|
151
|
+
}},
|
|
152
|
+
"output_from_tool": {{
|
|
153
|
+
"verdict": "PASS" | "FAIL" | "WARNING",
|
|
154
|
+
"is_relevant": true | false,
|
|
155
|
+
"is_correct": true | false,
|
|
156
|
+
"issues": ["<issue1>"]
|
|
157
|
+
}}
|
|
158
|
+
}}
|
|
159
|
+
}}
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
_TOOL_TYPES = {NodeType.Tool.value, NodeType.Retriever.value, "Tool", "Retriever"}
|
|
164
|
+
_AGENT_TYPES = {NodeType.Agent.value, NodeType.Router.value, "Agent", "Router"}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class ToolAgentEvaluator(BaseEvaluator):
|
|
168
|
+
"""Evaluator 3: Validates tool/sub-agent selection, input quality, and output quality."""
|
|
169
|
+
|
|
170
|
+
name = "ToolAgentEvaluator"
|
|
171
|
+
|
|
172
|
+
def should_run(self, node: ExecutionNode, graph: ExecutionGraph) -> bool:
|
|
173
|
+
# Run on Tool/Retriever nodes
|
|
174
|
+
if node.node_type in _TOOL_TYPES:
|
|
175
|
+
return True
|
|
176
|
+
# Run on any node that itself decided to call a tool — node-local,
|
|
177
|
+
# sourced straight off the LLM span's own tool_calls attribute, so
|
|
178
|
+
# this works regardless of framework/node_type (raw OpenAI/Anthropic
|
|
179
|
+
# SDK agents never emit a separate Tool-kind child span, so they'd
|
|
180
|
+
# otherwise never be evaluated at all).
|
|
181
|
+
if node.tool_calls:
|
|
182
|
+
return True
|
|
183
|
+
# Fallback: Agent/Router nodes with a Tool/Retriever child in the
|
|
184
|
+
# graph — covers frameworks (e.g. LangChain's AgentExecutor) whose
|
|
185
|
+
# wrapper chain node doesn't itself carry the raw tool_calls
|
|
186
|
+
# attribute, and doesn't expose the individual reasoning iterations
|
|
187
|
+
# as their own LLM spans either.
|
|
188
|
+
if node.node_type in _AGENT_TYPES:
|
|
189
|
+
children = self._get_children(node, graph)
|
|
190
|
+
has_tool_child = any(c.node_type in _TOOL_TYPES for c in children)
|
|
191
|
+
if not has_tool_child:
|
|
192
|
+
return False
|
|
193
|
+
# Some frameworks (CrewAI) report every reasoning/tool-call
|
|
194
|
+
# iteration of a multi-step task as its own LLM-kind sibling
|
|
195
|
+
# span under this same Agent/Task node, each already evaluated
|
|
196
|
+
# individually via the node-local tool_calls check above. In
|
|
197
|
+
# that case, this coarse whole-node fallback would re-bundle
|
|
198
|
+
# tool calls from separate, unrelated decision points into one
|
|
199
|
+
# out-of-context verdict — skip it; the granular evaluations
|
|
200
|
+
# already cover it correctly.
|
|
201
|
+
iterations_evaluated_individually = any(
|
|
202
|
+
c.node_type == NodeType.LLM.value and c.tool_calls for c in children
|
|
203
|
+
)
|
|
204
|
+
return not iterations_evaluated_individually
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
def _run(self, node: ExecutionNode, graph: ExecutionGraph) -> EvaluationResult:
|
|
208
|
+
if node.node_type in _TOOL_TYPES:
|
|
209
|
+
return self._evaluate_tool_node(node, graph)
|
|
210
|
+
else:
|
|
211
|
+
return self._evaluate_agent_node(node, graph)
|
|
212
|
+
|
|
213
|
+
def _evaluate_tool_node(
|
|
214
|
+
self, node: ExecutionNode, graph: ExecutionGraph
|
|
215
|
+
) -> EvaluationResult:
|
|
216
|
+
"""Evaluate from the perspective of the tool that was called."""
|
|
217
|
+
parent = self._get_parent(node, graph)
|
|
218
|
+
agent_context = (
|
|
219
|
+
self._format_node(parent)
|
|
220
|
+
if parent
|
|
221
|
+
else "(No parent agent context available)"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
prompt = _PROMPT_TOOL.format(
|
|
225
|
+
agent_context=agent_context,
|
|
226
|
+
tool_name=node.tool_name or node.name,
|
|
227
|
+
tool_desc=node.tool_desc or "(No description)",
|
|
228
|
+
tool_input=self._safe_json(node.input)[:2000],
|
|
229
|
+
tool_output=self._safe_json(node.output)[:2000],
|
|
230
|
+
tool_status=node.status,
|
|
231
|
+
tool_error=node.error or "(none)",
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
return self._parse_and_build(prompt, node)
|
|
235
|
+
|
|
236
|
+
def _evaluate_agent_node(
|
|
237
|
+
self, node: ExecutionNode, graph: ExecutionGraph
|
|
238
|
+
) -> EvaluationResult:
|
|
239
|
+
"""Evaluate from the perspective of an agent/LLM node that called tools."""
|
|
240
|
+
tools_called_lines = []
|
|
241
|
+
|
|
242
|
+
# Primary source: the tool-call decision(s) captured directly on this
|
|
243
|
+
# node's own LLM span attributes — framework-agnostic, requires no
|
|
244
|
+
# child Tool span to exist.
|
|
245
|
+
for call in node.tool_calls or []:
|
|
246
|
+
tools_called_lines.append(
|
|
247
|
+
f" Tool: {call.get('name')}\n"
|
|
248
|
+
f" Arguments: {self._safe_json(call.get('arguments'))[:500]}\n"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Secondary enrichment: if a real Tool/Retriever execution span does
|
|
252
|
+
# exist as a child (e.g. LangChain), include its actual input/output
|
|
253
|
+
# for richer context — optional, not required to run this evaluator.
|
|
254
|
+
children = self._get_children(node, graph)
|
|
255
|
+
tool_children = [c for c in children if c.node_type in _TOOL_TYPES]
|
|
256
|
+
for tc in tool_children:
|
|
257
|
+
tools_called_lines.append(
|
|
258
|
+
f" Tool: {tc.tool_name or tc.name}\n"
|
|
259
|
+
f" Input: {self._safe_json(tc.input)[:500]}\n"
|
|
260
|
+
f" Output: {self._safe_json(tc.output)[:500]}\n"
|
|
261
|
+
f" Status: {tc.status}\n"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
tools_called = "\n".join(tools_called_lines) if tools_called_lines else "(none)"
|
|
265
|
+
|
|
266
|
+
agent_context = self._format_node(node)
|
|
267
|
+
if node.available_tools:
|
|
268
|
+
available_desc = ", ".join(
|
|
269
|
+
(t.get("function", {}).get("name") if isinstance(t, dict) else str(t))
|
|
270
|
+
for t in node.available_tools
|
|
271
|
+
)
|
|
272
|
+
agent_context += f"\nTools available to choose from: {available_desc}"
|
|
273
|
+
|
|
274
|
+
prompt = _PROMPT_AGENT.format(
|
|
275
|
+
agent_context=agent_context,
|
|
276
|
+
tools_called=tools_called,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
return self._parse_and_build(prompt, node)
|
|
280
|
+
|
|
281
|
+
def _parse_and_build(self, prompt: str, node: ExecutionNode) -> EvaluationResult:
|
|
282
|
+
parsed = self._call_and_parse(prompt)
|
|
283
|
+
|
|
284
|
+
status_raw = parsed.get("status", "FAIL")
|
|
285
|
+
try:
|
|
286
|
+
status = EvaluationStatus(status_raw)
|
|
287
|
+
except ValueError:
|
|
288
|
+
status = EvaluationStatus.FAIL
|
|
289
|
+
|
|
290
|
+
severity = self._severity_from_status(parsed, default=Severity.MEDIUM)
|
|
291
|
+
|
|
292
|
+
return EvaluationResult(
|
|
293
|
+
evaluator=self.name,
|
|
294
|
+
node_id=node.node_id,
|
|
295
|
+
node_name=node.name,
|
|
296
|
+
node_type=node.node_type,
|
|
297
|
+
execution_id=node.execution_id,
|
|
298
|
+
session_id=node.session_id,
|
|
299
|
+
status=status,
|
|
300
|
+
severity=severity if status != EvaluationStatus.PASS else None,
|
|
301
|
+
confidence=float(parsed.get("confidence", 0.5)),
|
|
302
|
+
reason=parsed.get("reason", ""),
|
|
303
|
+
suggestion=parsed.get("suggestion"),
|
|
304
|
+
checks=parsed.get("checks", {}),
|
|
305
|
+
)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Graph builder package — converts execution traces into a MongoDB-backed graph."""
|
|
2
|
+
from .models import ExecutionNode, ExecutionEdge, ExecutionGraph
|
|
3
|
+
from .builder import TraceToGraphBuilder
|
|
4
|
+
from .mongo_store import MongoStore
|
|
5
|
+
|
|
6
|
+
__all__ = ["ExecutionNode", "ExecutionEdge", "ExecutionGraph", "TraceToGraphBuilder", "MongoStore"]
|