trace-agent-server 0.0.1__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.
@@ -0,0 +1,305 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections import defaultdict
5
+ from typing import Any
6
+
7
+ from sqlalchemy import select
8
+ from sqlalchemy.orm import Session
9
+
10
+ from trace_agent_server.config import Settings
11
+ from trace_agent_server.models import Event, Run
12
+ from trace_agent_server.services.run_summary import build_tool_chain
13
+
14
+
15
+ TOKEN_PATTERN = re.compile(r"[a-z0-9]+", re.IGNORECASE)
16
+ STOP_WORDS = {
17
+ "the",
18
+ "and",
19
+ "for",
20
+ "with",
21
+ "that",
22
+ "this",
23
+ "from",
24
+ "into",
25
+ "your",
26
+ "have",
27
+ "need",
28
+ "plan",
29
+ "build",
30
+ "using",
31
+ "use",
32
+ "agent",
33
+ "tool",
34
+ "tools",
35
+ }
36
+
37
+
38
+ def _goal_tokens(goal: str) -> set[str]:
39
+ tokens = {token.lower() for token in TOKEN_PATTERN.findall(goal)}
40
+ return {token for token in tokens if len(token) > 2 and token not in STOP_WORDS}
41
+
42
+
43
+ def _duration_ms(run: Run) -> int | None:
44
+ if run.ended_at is None:
45
+ return None
46
+ return max(0, int((run.ended_at - run.started_at).total_seconds() * 1000))
47
+
48
+
49
+ def _extract_usage(event: Event) -> dict[str, int]:
50
+ usage = event.event_metadata.get("usage", {})
51
+ if not isinstance(usage, dict):
52
+ return {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
53
+ return {
54
+ "prompt_tokens": int(usage.get("prompt_tokens") or 0),
55
+ "completion_tokens": int(usage.get("completion_tokens") or 0),
56
+ "total_tokens": int(usage.get("total_tokens") or 0),
57
+ }
58
+
59
+
60
+ def _pricing_key(provider: str | None, model: str | None) -> list[str]:
61
+ keys: list[str] = []
62
+ if provider and model:
63
+ keys.append(f"{provider}:{model}")
64
+ if model:
65
+ keys.append(model)
66
+ return keys
67
+
68
+
69
+ def build_tool_graph(tool_chain: list[dict[str, Any]]) -> dict[str, Any]:
70
+ nodes: list[dict[str, Any]] = []
71
+ edges: list[dict[str, Any]] = []
72
+ step_to_node: dict[str, str] = {}
73
+
74
+ for index, item in enumerate(tool_chain):
75
+ node_id = f"tool-chain-{item['step_id']}"
76
+ step_to_node[item["step_id"]] = node_id
77
+ nodes.append(
78
+ {
79
+ "id": node_id,
80
+ "type": "tool_chain",
81
+ "label": item["tool_name"],
82
+ "position": {"x": float(index * 220), "y": 80.0 if index % 2 == 0 else 180.0},
83
+ "data": {
84
+ "status": item["status"],
85
+ "durationMs": item["duration_ms"],
86
+ "callId": item["call_id"],
87
+ "summary": item["error_summary"] or item["output_summary"] or item["args_summary"],
88
+ "evidenceIds": item["event_ids"],
89
+ "artifactCount": item["artifact_count"],
90
+ },
91
+ }
92
+ )
93
+
94
+ for index, item in enumerate(tool_chain):
95
+ if index == 0:
96
+ continue
97
+ source_step = item["parent_step_id"] or tool_chain[index - 1]["step_id"]
98
+ source_node = step_to_node.get(source_step)
99
+ target_node = step_to_node[item["step_id"]]
100
+ if source_node is None:
101
+ source_node = step_to_node[tool_chain[index - 1]["step_id"]]
102
+ edges.append(
103
+ {
104
+ "id": f"tool-chain-edge-{source_node}-{target_node}",
105
+ "source": source_node,
106
+ "target": target_node,
107
+ "label": item["status"],
108
+ "animated": item["status"] == "failed",
109
+ }
110
+ )
111
+ return {"nodes": nodes, "edges": edges}
112
+
113
+
114
+ def build_tool_metrics(events: list[Event], tool_chain: list[dict[str, Any]], settings: Settings) -> list[dict[str, Any]]:
115
+ attribution: dict[str, dict[str, int]] = defaultdict(lambda: {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0})
116
+ for index, event in enumerate(events):
117
+ if event.type != "model.responded":
118
+ continue
119
+ tool_call_count = int(event.event_metadata.get("tool_call_count") or 0)
120
+ if tool_call_count <= 0:
121
+ continue
122
+
123
+ requested_events: list[Event] = []
124
+ cursor = index + 1
125
+ while cursor < len(events) and events[cursor].type == "tool.requested":
126
+ requested_events.append(events[cursor])
127
+ cursor += 1
128
+ if not requested_events:
129
+ continue
130
+
131
+ usage = _extract_usage(event)
132
+ divisor = max(1, len(requested_events))
133
+ # Split one model turn across the tool calls it produced.
134
+ for requested_event in requested_events:
135
+ key = requested_event.event_metadata.get("call_id") if isinstance(requested_event.event_metadata.get("call_id"), str) else requested_event.step_id
136
+ if not key:
137
+ continue
138
+ attribution[key]["prompt_tokens"] += usage["prompt_tokens"] // divisor
139
+ attribution[key]["completion_tokens"] += usage["completion_tokens"] // divisor
140
+ attribution[key]["total_tokens"] += usage["total_tokens"] // divisor
141
+
142
+ grouped: dict[str, dict[str, Any]] = {}
143
+ for item in tool_chain:
144
+ tool_name = item["tool_name"]
145
+ metric = grouped.setdefault(
146
+ tool_name,
147
+ {
148
+ "tool_name": tool_name,
149
+ "call_count": 0,
150
+ "success_count": 0,
151
+ "failure_count": 0,
152
+ "total_duration_ms": 0,
153
+ "max_duration_ms": 0,
154
+ "artifact_count": 0,
155
+ "attributed_prompt_tokens": 0,
156
+ "attributed_completion_tokens": 0,
157
+ "attributed_total_tokens": 0,
158
+ "estimated_cost_usd": 0.0,
159
+ "providers": set(),
160
+ "models": set(),
161
+ "_cost_available": False,
162
+ },
163
+ )
164
+ metric["call_count"] += 1
165
+ if item["status"] == "succeeded":
166
+ metric["success_count"] += 1
167
+ if item["status"] == "failed":
168
+ metric["failure_count"] += 1
169
+
170
+ duration = int(item["duration_ms"] or 0)
171
+ metric["total_duration_ms"] += duration
172
+ metric["max_duration_ms"] = max(metric["max_duration_ms"], duration)
173
+ metric["artifact_count"] += int(item["artifact_count"] or 0)
174
+ if item["provider"]:
175
+ metric["providers"].add(item["provider"])
176
+ if item["model"]:
177
+ metric["models"].add(item["model"])
178
+
179
+ key = item["call_id"] or item["step_id"]
180
+ usage = attribution.get(key, {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0})
181
+ metric["attributed_prompt_tokens"] += usage["prompt_tokens"]
182
+ metric["attributed_completion_tokens"] += usage["completion_tokens"]
183
+ metric["attributed_total_tokens"] += usage["total_tokens"]
184
+
185
+ pricing = None
186
+ for pricing_key in _pricing_key(item["provider"], item["model"]):
187
+ if pricing_key in settings.model_pricing:
188
+ pricing = settings.model_pricing[pricing_key]
189
+ break
190
+ if pricing:
191
+ prompt_rate = float(pricing.get("prompt_per_1k", 0.0))
192
+ completion_rate = float(pricing.get("completion_per_1k", 0.0))
193
+ metric["estimated_cost_usd"] += (
194
+ usage["prompt_tokens"] / 1000.0 * prompt_rate
195
+ + usage["completion_tokens"] / 1000.0 * completion_rate
196
+ )
197
+ metric["_cost_available"] = True
198
+
199
+ results: list[dict[str, Any]] = []
200
+ for metric in grouped.values():
201
+ call_count = max(1, metric["call_count"])
202
+ results.append(
203
+ {
204
+ "tool_name": metric["tool_name"],
205
+ "call_count": metric["call_count"],
206
+ "success_count": metric["success_count"],
207
+ "failure_count": metric["failure_count"],
208
+ "total_duration_ms": metric["total_duration_ms"],
209
+ "avg_duration_ms": int(metric["total_duration_ms"] / call_count),
210
+ "max_duration_ms": metric["max_duration_ms"],
211
+ "artifact_count": metric["artifact_count"],
212
+ "attributed_prompt_tokens": metric["attributed_prompt_tokens"],
213
+ "attributed_completion_tokens": metric["attributed_completion_tokens"],
214
+ "attributed_total_tokens": metric["attributed_total_tokens"],
215
+ "estimated_cost_usd": round(metric["estimated_cost_usd"], 4) if metric["_cost_available"] else None,
216
+ "providers": sorted(metric["providers"]),
217
+ "models": sorted(metric["models"]),
218
+ }
219
+ )
220
+ results.sort(key=lambda item: (-item["attributed_total_tokens"], -item["total_duration_ms"], item["tool_name"]))
221
+ return results
222
+
223
+
224
+ def build_similar_runs(
225
+ session: Session,
226
+ run: Run,
227
+ current_tool_chain: list[dict[str, Any]],
228
+ *,
229
+ limit: int = 5,
230
+ same_agent_only: bool = False,
231
+ status_filter: str | None = None,
232
+ shared_tools_only: bool = False,
233
+ min_score: float = 0.15,
234
+ ) -> list[dict[str, Any]]:
235
+ candidates = list(
236
+ session.scalars(
237
+ select(Run)
238
+ .where(Run.id != run.id)
239
+ .order_by(Run.started_at.desc())
240
+ .limit(40)
241
+ )
242
+ )
243
+ current_goal_tokens = _goal_tokens(run.goal)
244
+ current_tools = {item["tool_name"] for item in current_tool_chain}
245
+ current_scenario = run.run_metadata.get("scenario")
246
+ results: list[dict[str, Any]] = []
247
+
248
+ for candidate in candidates:
249
+ if same_agent_only and candidate.agent_name != run.agent_name:
250
+ continue
251
+ if status_filter and candidate.status != status_filter:
252
+ continue
253
+
254
+ candidate_events = list(session.scalars(select(Event).where(Event.run_id == candidate.id).order_by(Event.seq)))
255
+ candidate_tool_chain = build_tool_chain(candidate_events)
256
+ candidate_tools = {item["tool_name"] for item in candidate_tool_chain}
257
+ candidate_goal_tokens = _goal_tokens(candidate.goal)
258
+
259
+ goal_union = current_goal_tokens | candidate_goal_tokens
260
+ tool_union = current_tools | candidate_tools
261
+ goal_similarity = len(current_goal_tokens & candidate_goal_tokens) / len(goal_union) if goal_union else 0.0
262
+ tool_similarity = len(current_tools & candidate_tools) / len(tool_union) if tool_union else 0.0
263
+ score = 0.45 * goal_similarity + 0.2 * tool_similarity
264
+
265
+ reason_parts: list[str] = []
266
+ if candidate.agent_name == run.agent_name:
267
+ score += 0.2
268
+ reason_parts.append("same agent")
269
+ if current_scenario and candidate.run_metadata.get("scenario") == current_scenario:
270
+ score += 0.15
271
+ reason_parts.append("same scenario")
272
+ shared_tools = sorted(current_tools & candidate_tools)
273
+ if shared_tools_only and not shared_tools:
274
+ continue
275
+ if shared_tools:
276
+ reason_parts.append(f"shared tools: {', '.join(shared_tools[:3])}")
277
+ if goal_similarity > 0:
278
+ reason_parts.append("goal overlap")
279
+
280
+ # Keep the score simple and explainable: goal overlap, shared tools and same execution context.
281
+ if score < min_score:
282
+ continue
283
+
284
+ results.append(
285
+ {
286
+ "run_id": candidate.id,
287
+ "agent_name": candidate.agent_name,
288
+ "goal": candidate.goal,
289
+ "status": candidate.status,
290
+ "started_at": candidate.started_at,
291
+ "ended_at": candidate.ended_at,
292
+ "duration_ms": _duration_ms(candidate),
293
+ "tool_count": candidate.tool_count,
294
+ "error_count": candidate.error_count,
295
+ "retry_count": candidate.retry_count,
296
+ "provider": candidate.provider,
297
+ "model": candidate.model,
298
+ "similarity_score": round(min(score, 1.0), 3),
299
+ "shared_tools": shared_tools,
300
+ "reason": ", ".join(reason_parts) if reason_parts else "similar recent run",
301
+ }
302
+ )
303
+
304
+ results.sort(key=lambda item: (item["similarity_score"], item["started_at"]), reverse=True)
305
+ return results[:limit]
@@ -0,0 +1,303 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from trace_agent_server.config import Settings
8
+
9
+
10
+ EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
11
+ PHONE_RE = re.compile(r"\+?[0-9][0-9\s().-]{7,}[0-9]")
12
+ CREDIT_CARD_RE = re.compile(r"(?:\d[ -]*?){13,19}")
13
+ SECRET_TOKEN_RE = re.compile(r"\b(?:sk-[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{20,})\b")
14
+
15
+ PROMPT_INJECTION_PATTERNS = (
16
+ "ignore previous instructions",
17
+ "ignore all previous instructions",
18
+ "disregard previous instructions",
19
+ "reveal the system prompt",
20
+ "show hidden instructions",
21
+ "developer message",
22
+ "system prompt",
23
+ "act as",
24
+ "jailbreak",
25
+ "do anything now",
26
+ )
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class GuardrailCheckResult:
31
+ content: str
32
+ findings: list[dict[str, Any]]
33
+ blocked: bool = False
34
+ block_reason: str | None = None
35
+
36
+
37
+ class AuditGuardrails:
38
+ """Applies best-effort guardrails for prompt injection and PII leakage.
39
+
40
+ All integrations are optional. If third-party libraries are not available,
41
+ lightweight regex heuristics are used instead.
42
+ """
43
+
44
+ def __init__(self, settings: Settings, observability: Any | None = None) -> None:
45
+ self.enabled = settings.audit_enable_guardrails
46
+ self.fail_on_prompt_injection = settings.audit_fail_on_prompt_injection
47
+ self.enable_pii_anonymization = settings.audit_enable_pii_anonymization
48
+ self.prompt_injection_threshold = max(0.0, min(1.0, settings.audit_prompt_injection_threshold))
49
+ self.pii_entities = settings.audit_pii_entities
50
+ self.observability = observability
51
+
52
+ self._analyzer = None
53
+ self._anonymizer = None
54
+ self._llm_guard_scanner = None
55
+ self._initialize_optional_engines()
56
+
57
+ def sanitize_messages(
58
+ self,
59
+ messages: list[dict[str, Any]],
60
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], bool, str | None]:
61
+ if not self.enabled:
62
+ return messages, [], False, None
63
+
64
+ sanitized_messages: list[dict[str, Any]] = []
65
+ findings: list[dict[str, Any]] = []
66
+ blocked = False
67
+ block_reason: str | None = None
68
+
69
+ for index, message in enumerate(messages):
70
+ updated = dict(message)
71
+ content = message.get("content")
72
+ if isinstance(content, str):
73
+ pii_check = self._apply_pii_filters(content)
74
+ injection_findings, injection_score = self._scan_prompt_injection(pii_check.content)
75
+
76
+ for finding in pii_check.findings:
77
+ findings.append({**finding, "scope": "input", "message_index": index})
78
+ for finding in injection_findings:
79
+ findings.append({**finding, "scope": "input", "message_index": index})
80
+
81
+ if self.fail_on_prompt_injection and injection_score >= self.prompt_injection_threshold:
82
+ blocked = True
83
+ block_reason = "prompt_injection"
84
+
85
+ updated["content"] = pii_check.content
86
+
87
+ sanitized_messages.append(updated)
88
+
89
+ self._record_findings(findings, blocked=blocked, block_reason=block_reason)
90
+ return sanitized_messages, findings, blocked, block_reason
91
+
92
+ def sanitize_assistant_message(
93
+ self,
94
+ message: dict[str, Any] | None,
95
+ ) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
96
+ if not self.enabled or message is None:
97
+ return message, []
98
+
99
+ updated = dict(message)
100
+ findings: list[dict[str, Any]] = []
101
+
102
+ content = message.get("content")
103
+ if isinstance(content, str):
104
+ pii_check = self._apply_pii_filters(content)
105
+ secret_findings, scrubbed_content = self._scrub_secrets(pii_check.content)
106
+ updated["content"] = scrubbed_content
107
+
108
+ findings.extend({**finding, "scope": "output"} for finding in pii_check.findings)
109
+ findings.extend({**finding, "scope": "output"} for finding in secret_findings)
110
+
111
+ self._record_findings(findings, blocked=False, block_reason=None)
112
+ return updated, findings
113
+
114
+ def _initialize_optional_engines(self) -> None:
115
+ if self.enable_pii_anonymization:
116
+ try:
117
+ from presidio_analyzer import AnalyzerEngine # type: ignore
118
+ from presidio_anonymizer import AnonymizerEngine # type: ignore
119
+
120
+ self._analyzer = AnalyzerEngine()
121
+ self._anonymizer = AnonymizerEngine()
122
+ except Exception:
123
+ self._analyzer = None
124
+ self._anonymizer = None
125
+
126
+ if self.enabled:
127
+ try:
128
+ from llm_guard.input_scanners import PromptInjection # type: ignore
129
+
130
+ self._llm_guard_scanner = PromptInjection(threshold=self.prompt_injection_threshold)
131
+ except Exception:
132
+ self._llm_guard_scanner = None
133
+
134
+ def _apply_pii_filters(self, content: str) -> GuardrailCheckResult:
135
+ if not self.enable_pii_anonymization:
136
+ return GuardrailCheckResult(content=content, findings=[])
137
+
138
+ if self._analyzer is not None and self._anonymizer is not None:
139
+ return self._apply_presidio(content)
140
+
141
+ return self._apply_regex_pii_fallback(content)
142
+
143
+ def _apply_presidio(self, content: str) -> GuardrailCheckResult:
144
+ try:
145
+ analyzer_results = self._analyzer.analyze(
146
+ text=content,
147
+ entities=self.pii_entities or None,
148
+ language="en",
149
+ )
150
+ if not analyzer_results:
151
+ return GuardrailCheckResult(content=content, findings=[])
152
+
153
+ anonymized = self._anonymizer.anonymize(
154
+ text=content,
155
+ analyzer_results=analyzer_results,
156
+ )
157
+ findings = [
158
+ {
159
+ "kind": "pii",
160
+ "severity": "medium",
161
+ "source": "presidio",
162
+ "entity": result.entity_type,
163
+ "score": round(float(result.score), 4),
164
+ "start": int(result.start),
165
+ "end": int(result.end),
166
+ "message": f"Detected {result.entity_type}.",
167
+ }
168
+ for result in analyzer_results
169
+ ]
170
+ return GuardrailCheckResult(content=anonymized.text, findings=findings)
171
+ except Exception:
172
+ return self._apply_regex_pii_fallback(content)
173
+
174
+ def _apply_regex_pii_fallback(self, content: str) -> GuardrailCheckResult:
175
+ findings: list[dict[str, Any]] = []
176
+ scrubbed = content
177
+
178
+ for pattern, replacement, entity in (
179
+ (EMAIL_RE, "[redacted:email]", "EMAIL_ADDRESS"),
180
+ (PHONE_RE, "[redacted:phone]", "PHONE_NUMBER"),
181
+ (CREDIT_CARD_RE, "[redacted:card]", "CREDIT_CARD"),
182
+ ):
183
+ matches = list(pattern.finditer(scrubbed))
184
+ if not matches:
185
+ continue
186
+ scrubbed = pattern.sub(replacement, scrubbed)
187
+ findings.append(
188
+ {
189
+ "kind": "pii",
190
+ "severity": "medium",
191
+ "source": "regex",
192
+ "entity": entity,
193
+ "count": len(matches),
194
+ "message": f"Detected {len(matches)} {entity} match(es).",
195
+ }
196
+ )
197
+
198
+ return GuardrailCheckResult(content=scrubbed, findings=findings)
199
+
200
+ def _scan_prompt_injection(self, content: str) -> tuple[list[dict[str, Any]], float]:
201
+ findings: list[dict[str, Any]] = []
202
+ max_score = 0.0
203
+
204
+ normalized = content.lower()
205
+ matched = [pattern for pattern in PROMPT_INJECTION_PATTERNS if pattern in normalized]
206
+ if matched:
207
+ score = min(1.0, 0.35 + (0.15 * len(matched)))
208
+ max_score = max(max_score, score)
209
+ findings.append(
210
+ {
211
+ "kind": "prompt_injection",
212
+ "severity": "high" if score >= self.prompt_injection_threshold else "medium",
213
+ "source": "heuristic",
214
+ "score": round(score, 4),
215
+ "patterns": matched,
216
+ "message": "Detected patterns commonly associated with prompt injection.",
217
+ }
218
+ )
219
+
220
+ if self._llm_guard_scanner is not None:
221
+ llm_guard_findings, llm_guard_score = self._scan_with_llm_guard(content)
222
+ findings.extend(llm_guard_findings)
223
+ max_score = max(max_score, llm_guard_score)
224
+
225
+ return findings, max_score
226
+
227
+ def _scan_with_llm_guard(self, content: str) -> tuple[list[dict[str, Any]], float]:
228
+ try:
229
+ scan_result = self._llm_guard_scanner.scan(content)
230
+ except Exception:
231
+ return [], 0.0
232
+
233
+ score = 0.0
234
+ verdict = "unknown"
235
+ if isinstance(scan_result, tuple):
236
+ for value in scan_result:
237
+ if isinstance(value, bool):
238
+ verdict = "safe" if value else "unsafe"
239
+ if isinstance(value, (int, float)):
240
+ score = max(score, float(value))
241
+ if isinstance(value, dict):
242
+ score = max(score, self._extract_score_from_dict(value))
243
+ elif isinstance(scan_result, dict):
244
+ score = self._extract_score_from_dict(scan_result)
245
+ unsafe = scan_result.get("is_valid") is False
246
+ verdict = "unsafe" if unsafe else "safe"
247
+
248
+ if score <= 0:
249
+ return [], 0.0
250
+
251
+ finding = {
252
+ "kind": "prompt_injection",
253
+ "severity": "high" if score >= self.prompt_injection_threshold else "medium",
254
+ "source": "llm_guard",
255
+ "score": round(score, 4),
256
+ "message": f"LLM Guard classified prompt as {verdict}.",
257
+ }
258
+ return [finding], score
259
+
260
+ @staticmethod
261
+ def _extract_score_from_dict(value: dict[str, Any]) -> float:
262
+ candidates = (
263
+ value.get("risk"),
264
+ value.get("risk_score"),
265
+ value.get("score"),
266
+ value.get("confidence"),
267
+ )
268
+ for candidate in candidates:
269
+ if isinstance(candidate, (int, float)):
270
+ return float(candidate)
271
+ return 0.0
272
+
273
+ def _scrub_secrets(self, content: str) -> tuple[list[dict[str, Any]], str]:
274
+ matches = list(SECRET_TOKEN_RE.finditer(content))
275
+ if not matches:
276
+ return [], content
277
+
278
+ scrubbed = SECRET_TOKEN_RE.sub("[redacted:secret]", content)
279
+ findings = [
280
+ {
281
+ "kind": "secret",
282
+ "severity": "high",
283
+ "source": "regex",
284
+ "count": len(matches),
285
+ "message": "Detected possible API tokens in assistant output.",
286
+ }
287
+ ]
288
+ return findings, scrubbed
289
+
290
+ def _record_findings(self, findings: list[dict[str, Any]], *, blocked: bool, block_reason: str | None) -> None:
291
+ if self.observability is None:
292
+ return
293
+
294
+ try:
295
+ for finding in findings:
296
+ self.observability.record_guardrail_finding(
297
+ kind=str(finding.get("kind") or "unknown"),
298
+ severity=str(finding.get("severity") or "unknown"),
299
+ )
300
+ if blocked:
301
+ self.observability.record_guardrail_block(block_reason or "blocked")
302
+ except Exception:
303
+ return