tracectrl 0.2.0__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tracectrl-0.2.0 → tracectrl-0.3.1}/PKG-INFO +2 -2
- {tracectrl-0.2.0 → tracectrl-0.3.1}/README.md +1 -1
- {tracectrl-0.2.0 → tracectrl-0.3.1}/pyproject.toml +1 -1
- {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/__init__.py +1 -1
- tracectrl-0.3.1/src/tracectrl/guardrails/judge.py +417 -0
- {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/guardrails/strands_hook.py +121 -25
- tracectrl-0.2.0/src/tracectrl/guardrails/judge.py +0 -205
- {tracectrl-0.2.0 → tracectrl-0.3.1}/.gitignore +0 -0
- {tracectrl-0.2.0 → tracectrl-0.3.1}/LICENSE +0 -0
- {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/_tui.py +0 -0
- {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/agent_tagging.py +0 -0
- {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/cli.py +0 -0
- {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/config.py +0 -0
- {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/context.py +0 -0
- {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/exporter.py +0 -0
- {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/guardrails/__init__.py +0 -0
- {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/guardrails/guardrail.py +0 -0
- {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/inference.py +0 -0
- {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/processor.py +0 -0
- {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/protector.py +0 -0
- {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/schema.py +0 -0
- {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/session.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tracectrl
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: TraceCtrl SDK — agentic AI security observability
|
|
5
5
|
Author: CloudsineAI
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -52,7 +52,7 @@ StrandsInstrumentor().instrument()
|
|
|
52
52
|
|
|
53
53
|
Two guardrail providers, designed to coexist on the same agent:
|
|
54
54
|
|
|
55
|
-
**1. Built-in LLM judge** — declarative guardrails evaluated by a Bedrock model:
|
|
55
|
+
**1. Built-in LLM judge** — declarative guardrails evaluated by a Bedrock OR Gemini model (auto-detected from the `judge_llm` you pass in):
|
|
56
56
|
|
|
57
57
|
```python
|
|
58
58
|
from tracectrl.guardrails import Guardrail, wrap_agent_with_guardrails
|
|
@@ -37,7 +37,7 @@ StrandsInstrumentor().instrument()
|
|
|
37
37
|
|
|
38
38
|
Two guardrail providers, designed to coexist on the same agent:
|
|
39
39
|
|
|
40
|
-
**1. Built-in LLM judge** — declarative guardrails evaluated by a Bedrock model:
|
|
40
|
+
**1. Built-in LLM judge** — declarative guardrails evaluated by a Bedrock OR Gemini model (auto-detected from the `judge_llm` you pass in):
|
|
41
41
|
|
|
42
42
|
```python
|
|
43
43
|
from tracectrl.guardrails import Guardrail, wrap_agent_with_guardrails
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"""Judge LLM invocation with structured output parsing.
|
|
2
|
+
|
|
3
|
+
Supports two backends, picked by inspecting the `judge_llm` argument:
|
|
4
|
+
|
|
5
|
+
- **Strands BedrockModel** (default for everything we don't recognise) —
|
|
6
|
+
Calls boto3's `bedrock-runtime.converse` directly with a single-tool
|
|
7
|
+
schema. We bind to boto3 instead of going through Strands'
|
|
8
|
+
`BedrockModel.structured_output` because that public surface is async
|
|
9
|
+
and its method names have shifted across versions.
|
|
10
|
+
|
|
11
|
+
- **Strands GeminiModel** — Calls Google's genai SDK via the client
|
|
12
|
+
object embedded in the GeminiModel. We force structured output by
|
|
13
|
+
setting `response_mime_type="application/json"` plus an OpenAPI-style
|
|
14
|
+
`response_schema`. No AWS credentials required — the same
|
|
15
|
+
`GOOGLE_API_KEY` the workshop's agents are using is enough.
|
|
16
|
+
|
|
17
|
+
Both backends produce a `JudgeResult` directly so the retry loop in
|
|
18
|
+
`invoke_judge` is provider-agnostic.
|
|
19
|
+
|
|
20
|
+
On invocation/parse failure we re-prompt once; a second failure defaults to
|
|
21
|
+
`pass=true` (a broken judge must not spam violation alerts).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Any, Optional
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
# Single tool the judge is forced to call. Schema matches the PRD exactly.
|
|
34
|
+
_JUDGE_TOOL_NAME = "record_decision"
|
|
35
|
+
_JUDGE_TOOL_SCHEMA = {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"properties": {
|
|
38
|
+
"pass": {
|
|
39
|
+
"type": "boolean",
|
|
40
|
+
"description": "true if the output satisfies the guardrail; false if it violates.",
|
|
41
|
+
},
|
|
42
|
+
"reason": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"description": "One-sentence explanation of the decision.",
|
|
45
|
+
},
|
|
46
|
+
"evidence": {
|
|
47
|
+
"type": ["string", "null"],
|
|
48
|
+
"description": "Verbatim snippet that triggered a fail; null if pass.",
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
"required": ["pass", "reason"],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Gemini's response_schema lives under the Vertex / OpenAPI dialect — it
|
|
56
|
+
# doesn't accept union types like ["string", "null"]. We drop `evidence` from
|
|
57
|
+
# `required` so the model can omit it on a pass; if it sets it to an empty
|
|
58
|
+
# string, the parser below normalises to None for symmetry with the Bedrock
|
|
59
|
+
# JudgeResult shape.
|
|
60
|
+
_GEMINI_JUDGE_SCHEMA = {
|
|
61
|
+
"type": "object",
|
|
62
|
+
"properties": {
|
|
63
|
+
"pass": {
|
|
64
|
+
"type": "boolean",
|
|
65
|
+
"description": "true if the output satisfies the guardrail; false if it violates.",
|
|
66
|
+
},
|
|
67
|
+
"reason": {
|
|
68
|
+
"type": "string",
|
|
69
|
+
"description": "One-sentence explanation of the decision.",
|
|
70
|
+
},
|
|
71
|
+
"evidence": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"description": "Verbatim snippet that triggered a fail; empty string if pass.",
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
"required": ["pass", "reason"],
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class JudgeResult:
|
|
82
|
+
passed: bool
|
|
83
|
+
reason: str
|
|
84
|
+
evidence: Optional[str]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def invoke_judge(judge_llm: Any, prompt: str) -> JudgeResult:
|
|
88
|
+
"""Dispatch to the right backend (Bedrock or Gemini), retry once on failure,
|
|
89
|
+
default-pass on a second failure.
|
|
90
|
+
|
|
91
|
+
Picking the backend by type means existing BedrockModel callers see ZERO
|
|
92
|
+
behavioural change — the dispatch falls through to the Bedrock path
|
|
93
|
+
untouched. GeminiModel callers go to a parallel Gemini path that doesn't
|
|
94
|
+
need AWS credentials.
|
|
95
|
+
"""
|
|
96
|
+
invoker = _resolve_invoker(judge_llm)
|
|
97
|
+
last_err: Optional[Exception] = None
|
|
98
|
+
for attempt in (1, 2):
|
|
99
|
+
try:
|
|
100
|
+
return invoker(judge_llm, prompt, attempt=attempt)
|
|
101
|
+
except Exception as exc: # noqa: BLE001 — broad on purpose; retry once
|
|
102
|
+
last_err = exc
|
|
103
|
+
logger.warning(
|
|
104
|
+
"judge attempt %d failed via %s: %s",
|
|
105
|
+
attempt,
|
|
106
|
+
getattr(invoker, "__name__", "unknown"),
|
|
107
|
+
exc,
|
|
108
|
+
)
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
logger.warning(
|
|
112
|
+
"guardrail judge failed to produce valid JSON twice; defaulting to pass (last error: %s)",
|
|
113
|
+
last_err,
|
|
114
|
+
)
|
|
115
|
+
return JudgeResult(passed=True, reason="judge parse failed; defaulted to pass", evidence=None)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _resolve_invoker(judge_llm: Any):
|
|
119
|
+
"""Pick the backend by type. Defaults to Bedrock for backward compat —
|
|
120
|
+
anything not specifically recognised falls through to the original path."""
|
|
121
|
+
if _is_gemini_model(judge_llm):
|
|
122
|
+
return _invoke_gemini_judge
|
|
123
|
+
return _invoke_bedrock_judge
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _is_gemini_model(judge_llm: Any) -> bool:
|
|
127
|
+
"""True only for a real Strands GeminiModel instance. Lazy-imports so
|
|
128
|
+
SDK callers without `strands.models.gemini` (older Strands, custom
|
|
129
|
+
builds) keep working — they just always go to the Bedrock path."""
|
|
130
|
+
try:
|
|
131
|
+
from strands.models.gemini import GeminiModel # type: ignore
|
|
132
|
+
except ImportError:
|
|
133
|
+
return False
|
|
134
|
+
return isinstance(judge_llm, GeminiModel)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _invoke_bedrock_judge(judge_llm: Any, prompt: str, *, attempt: int) -> JudgeResult:
|
|
138
|
+
"""Bedrock path — uses boto3.bedrock-runtime.converse with tool-forcing.
|
|
139
|
+
|
|
140
|
+
This is the original implementation, refactored only to return a
|
|
141
|
+
JudgeResult directly so it shares the dispatcher's retry shape with the
|
|
142
|
+
Gemini path. The underlying `_call_model` + `_parse_judge_response` are
|
|
143
|
+
unchanged.
|
|
144
|
+
"""
|
|
145
|
+
raw = _call_model(judge_llm, prompt, attempt=attempt)
|
|
146
|
+
return _parse_judge_response(raw)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _resolve_bedrock_model(judge_llm: Any) -> tuple[str, str]:
|
|
150
|
+
"""Pull (model_id, region) from a Strands BedrockModel or from explicit config."""
|
|
151
|
+
# Strands BedrockModel stores config in `_config` / `get_config()`.
|
|
152
|
+
config: dict = {}
|
|
153
|
+
if hasattr(judge_llm, "get_config"):
|
|
154
|
+
try:
|
|
155
|
+
cfg = judge_llm.get_config()
|
|
156
|
+
if isinstance(cfg, dict):
|
|
157
|
+
config = cfg
|
|
158
|
+
except Exception: # noqa: BLE001
|
|
159
|
+
pass
|
|
160
|
+
if not config and hasattr(judge_llm, "config"):
|
|
161
|
+
c = judge_llm.config
|
|
162
|
+
if isinstance(c, dict):
|
|
163
|
+
config = c
|
|
164
|
+
model_id = (
|
|
165
|
+
config.get("model_id")
|
|
166
|
+
or getattr(judge_llm, "model_id", None)
|
|
167
|
+
or getattr(judge_llm, "model", None)
|
|
168
|
+
)
|
|
169
|
+
region = (
|
|
170
|
+
config.get("region_name")
|
|
171
|
+
or getattr(judge_llm, "region_name", None)
|
|
172
|
+
or "us-east-1"
|
|
173
|
+
)
|
|
174
|
+
if not model_id:
|
|
175
|
+
raise RuntimeError(f"could not extract model_id from judge_llm: {type(judge_llm).__name__}")
|
|
176
|
+
return model_id, region
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _call_model(judge_llm: Any, prompt: str, *, attempt: int) -> Any:
|
|
180
|
+
"""Call Bedrock converse with tool-use forcing the JSON schema.
|
|
181
|
+
|
|
182
|
+
boto3 is bundled with every AWS Lambda / Strands deploy; importing it lazily
|
|
183
|
+
here keeps the SDK's import-time footprint clean.
|
|
184
|
+
"""
|
|
185
|
+
import boto3
|
|
186
|
+
|
|
187
|
+
model_id, region = _resolve_bedrock_model(judge_llm)
|
|
188
|
+
|
|
189
|
+
system = (
|
|
190
|
+
"You are an automated guardrail judge. You MUST call the "
|
|
191
|
+
f"`{_JUDGE_TOOL_NAME}` tool with your decision. Do not answer in plain text."
|
|
192
|
+
)
|
|
193
|
+
if attempt == 2:
|
|
194
|
+
system += " Your previous response was not valid JSON; respond by calling the tool exactly."
|
|
195
|
+
|
|
196
|
+
client = boto3.client("bedrock-runtime", region_name=region)
|
|
197
|
+
response = client.converse(
|
|
198
|
+
modelId=model_id,
|
|
199
|
+
messages=[{"role": "user", "content": [{"text": prompt}]}],
|
|
200
|
+
system=[{"text": system}],
|
|
201
|
+
toolConfig={
|
|
202
|
+
"tools": [{
|
|
203
|
+
"toolSpec": {
|
|
204
|
+
"name": _JUDGE_TOOL_NAME,
|
|
205
|
+
"description": "Record the guardrail pass/fail decision.",
|
|
206
|
+
"inputSchema": {"json": _JUDGE_TOOL_SCHEMA},
|
|
207
|
+
}
|
|
208
|
+
}],
|
|
209
|
+
# `any` forces the model to call SOME tool; combined with a single
|
|
210
|
+
# tool in the list this guarantees we get our schema back.
|
|
211
|
+
"toolChoice": {"any": {}},
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
return response
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _parse_judge_response(raw: Any) -> JudgeResult:
|
|
218
|
+
"""Extract the structured decision from a Bedrock converse response."""
|
|
219
|
+
payload: Optional[dict] = None
|
|
220
|
+
|
|
221
|
+
# Bedrock converse response shape: {output: {message: {content: [{toolUse: {input: {...}}}]}}}
|
|
222
|
+
if isinstance(raw, dict):
|
|
223
|
+
output = raw.get("output") or {}
|
|
224
|
+
message = output.get("message") if isinstance(output, dict) else None
|
|
225
|
+
if isinstance(message, dict):
|
|
226
|
+
for block in message.get("content", []) or []:
|
|
227
|
+
if isinstance(block, dict) and "toolUse" in block:
|
|
228
|
+
payload = block["toolUse"].get("input")
|
|
229
|
+
break
|
|
230
|
+
if payload is None:
|
|
231
|
+
# Some intermediaries flatten this — try direct keys.
|
|
232
|
+
payload = raw.get("input") or raw.get("toolUse", {}).get("input")
|
|
233
|
+
|
|
234
|
+
# Plain text fallback — try to find a JSON object in the string.
|
|
235
|
+
if payload is None:
|
|
236
|
+
text = _stringify(raw)
|
|
237
|
+
payload = _extract_json_object(text)
|
|
238
|
+
|
|
239
|
+
if not isinstance(payload, dict):
|
|
240
|
+
raise ValueError(f"could not extract JSON object from judge response: {raw!r}")
|
|
241
|
+
|
|
242
|
+
if "pass" not in payload or "reason" not in payload:
|
|
243
|
+
raise ValueError(f"judge JSON missing required keys: {payload!r}")
|
|
244
|
+
|
|
245
|
+
return JudgeResult(
|
|
246
|
+
passed=bool(payload["pass"]),
|
|
247
|
+
reason=str(payload.get("reason", "")),
|
|
248
|
+
evidence=(str(payload["evidence"]) if payload.get("evidence") else None),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _stringify(raw: Any) -> str:
|
|
253
|
+
if isinstance(raw, str):
|
|
254
|
+
return raw
|
|
255
|
+
if isinstance(raw, dict):
|
|
256
|
+
return json.dumps(raw)
|
|
257
|
+
text = getattr(raw, "text", None)
|
|
258
|
+
if isinstance(text, str):
|
|
259
|
+
return text
|
|
260
|
+
return str(raw)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _extract_json_object(text: str) -> Optional[dict]:
|
|
264
|
+
"""Find the first balanced top-level JSON object in `text`."""
|
|
265
|
+
start = text.find("{")
|
|
266
|
+
while start != -1:
|
|
267
|
+
depth = 0
|
|
268
|
+
for i in range(start, len(text)):
|
|
269
|
+
ch = text[i]
|
|
270
|
+
if ch == "{":
|
|
271
|
+
depth += 1
|
|
272
|
+
elif ch == "}":
|
|
273
|
+
depth -= 1
|
|
274
|
+
if depth == 0:
|
|
275
|
+
candidate = text[start : i + 1]
|
|
276
|
+
try:
|
|
277
|
+
obj = json.loads(candidate)
|
|
278
|
+
if isinstance(obj, dict):
|
|
279
|
+
return obj
|
|
280
|
+
except json.JSONDecodeError:
|
|
281
|
+
break
|
|
282
|
+
start = text.find("{", start + 1)
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
# Gemini backend
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _invoke_gemini_judge(judge_llm: Any, prompt: str, *, attempt: int) -> JudgeResult:
|
|
292
|
+
"""Gemini path — uses the `google.genai` client embedded in Strands'
|
|
293
|
+
GeminiModel. No AWS credentials required.
|
|
294
|
+
|
|
295
|
+
We force structured output via `response_mime_type='application/json'`
|
|
296
|
+
plus an OpenAPI-style schema (`_GEMINI_JUDGE_SCHEMA`). On the second
|
|
297
|
+
attempt we sharpen the system instruction so the model recovers from
|
|
298
|
+
whatever malformed-JSON cause the first attempt hit.
|
|
299
|
+
"""
|
|
300
|
+
client = _resolve_gemini_client(judge_llm)
|
|
301
|
+
|
|
302
|
+
model_id = _resolve_gemini_model_id(judge_llm)
|
|
303
|
+
|
|
304
|
+
system_text = (
|
|
305
|
+
"You are an automated guardrail judge. Respond with ONLY a JSON "
|
|
306
|
+
"object matching the schema {pass: bool, reason: string, evidence: "
|
|
307
|
+
"string}. On pass, evidence may be an empty string. On fail, "
|
|
308
|
+
"evidence must be the verbatim snippet that triggered the fail "
|
|
309
|
+
"(max ~200 chars). Do not include any text outside the JSON."
|
|
310
|
+
)
|
|
311
|
+
if attempt == 2:
|
|
312
|
+
system_text += (
|
|
313
|
+
" Your previous response was not parseable. Return strict JSON "
|
|
314
|
+
"with no preamble, no markdown fences, no commentary."
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
# Lazy import — keeps SDK import-time clean for callers that never use Gemini.
|
|
318
|
+
from google.genai import types as genai_types # type: ignore
|
|
319
|
+
|
|
320
|
+
response = client.models.generate_content(
|
|
321
|
+
model=model_id,
|
|
322
|
+
contents=prompt,
|
|
323
|
+
config=genai_types.GenerateContentConfig(
|
|
324
|
+
response_mime_type="application/json",
|
|
325
|
+
response_schema=_GEMINI_JUDGE_SCHEMA,
|
|
326
|
+
system_instruction=system_text,
|
|
327
|
+
# Low temperature — judges should be near-deterministic.
|
|
328
|
+
temperature=0.0,
|
|
329
|
+
),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
text = (response.text or "").strip()
|
|
333
|
+
if not text:
|
|
334
|
+
raise ValueError("gemini judge returned empty body")
|
|
335
|
+
|
|
336
|
+
payload = json.loads(text)
|
|
337
|
+
|
|
338
|
+
if "pass" not in payload or "reason" not in payload:
|
|
339
|
+
raise ValueError(f"gemini judge JSON missing required keys: {payload!r}")
|
|
340
|
+
|
|
341
|
+
# Normalise empty-string evidence to None so downstream consumers can
|
|
342
|
+
# treat 'no evidence' uniformly regardless of backend. Bedrock's path
|
|
343
|
+
# already does this via the explicit "null" union type.
|
|
344
|
+
raw_evidence = payload.get("evidence")
|
|
345
|
+
evidence = str(raw_evidence) if raw_evidence else None
|
|
346
|
+
|
|
347
|
+
return JudgeResult(
|
|
348
|
+
passed=bool(payload["pass"]),
|
|
349
|
+
reason=str(payload.get("reason", "")),
|
|
350
|
+
evidence=evidence,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _resolve_gemini_client(judge_llm: Any) -> Any:
|
|
355
|
+
"""Return a cached `google.genai.Client` for this judge_llm, building it
|
|
356
|
+
once and stashing it on the judge_llm instance.
|
|
357
|
+
|
|
358
|
+
Strands' `GeminiModel` does NOT expose a `.client` attribute — it stores
|
|
359
|
+
`_custom_client` + `client_args` and builds a fresh `genai.Client` on
|
|
360
|
+
every request via `_get_client()`. Before this cache, every guardrail
|
|
361
|
+
evaluation was constructing a brand new `genai.Client` (with its own
|
|
362
|
+
httpx pool and credential setup), which under sustained load against
|
|
363
|
+
the Gemini preview models has been observed to stall judge calls and
|
|
364
|
+
starve subsequent agent invocations of FDs. One client per judge_llm
|
|
365
|
+
is enough — `genai.Client` is documented as not safe to share across
|
|
366
|
+
asyncio event loops, but we only call it from the synchronous path on
|
|
367
|
+
a dedicated thread, so a single instance is correct here.
|
|
368
|
+
"""
|
|
369
|
+
cached = getattr(judge_llm, "_tracectrl_genai_client", None)
|
|
370
|
+
if cached is not None:
|
|
371
|
+
return cached
|
|
372
|
+
# If the GeminiModel was constructed with an injected client, honour it.
|
|
373
|
+
injected = getattr(judge_llm, "_custom_client", None)
|
|
374
|
+
if injected is not None:
|
|
375
|
+
return injected
|
|
376
|
+
client_args = getattr(judge_llm, "client_args", None) or {}
|
|
377
|
+
try:
|
|
378
|
+
from google import genai # type: ignore
|
|
379
|
+
except ImportError as e:
|
|
380
|
+
raise RuntimeError(
|
|
381
|
+
"GeminiModel passed as judge_llm but `google-genai` is not "
|
|
382
|
+
"installed. `pip install google-genai`."
|
|
383
|
+
) from e
|
|
384
|
+
client = genai.Client(**client_args)
|
|
385
|
+
try:
|
|
386
|
+
judge_llm._tracectrl_genai_client = client
|
|
387
|
+
except Exception: # noqa: BLE001 — frozen dataclasses etc.
|
|
388
|
+
pass
|
|
389
|
+
return client
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _resolve_gemini_model_id(judge_llm: Any) -> str:
|
|
393
|
+
"""Extract model_id from a Strands GeminiModel. Mirrors the
|
|
394
|
+
Bedrock-side `_resolve_bedrock_model` shape but returns just the id —
|
|
395
|
+
Gemini doesn't need a region."""
|
|
396
|
+
config: dict = {}
|
|
397
|
+
if hasattr(judge_llm, "get_config"):
|
|
398
|
+
try:
|
|
399
|
+
cfg = judge_llm.get_config()
|
|
400
|
+
if isinstance(cfg, dict):
|
|
401
|
+
config = cfg
|
|
402
|
+
except Exception: # noqa: BLE001
|
|
403
|
+
pass
|
|
404
|
+
if not config and hasattr(judge_llm, "config"):
|
|
405
|
+
c = judge_llm.config
|
|
406
|
+
if isinstance(c, dict):
|
|
407
|
+
config = c
|
|
408
|
+
model_id = (
|
|
409
|
+
config.get("model_id")
|
|
410
|
+
or getattr(judge_llm, "model_id", None)
|
|
411
|
+
or getattr(judge_llm, "model", None)
|
|
412
|
+
)
|
|
413
|
+
if not model_id:
|
|
414
|
+
raise RuntimeError(
|
|
415
|
+
f"could not extract model_id from GeminiModel: {type(judge_llm).__name__}"
|
|
416
|
+
)
|
|
417
|
+
return model_id
|
|
@@ -6,14 +6,35 @@ callbacks. So we wrap the agent's `__call__` method directly: run the agent,
|
|
|
6
6
|
capture its response, then evaluate each guardrail in order. This keeps the
|
|
7
7
|
core `Guardrail` class framework-agnostic and isolates the Strands knowledge
|
|
8
8
|
to this file.
|
|
9
|
+
|
|
10
|
+
Two correctness details that bit us before:
|
|
11
|
+
|
|
12
|
+
- **Post-output evals run on a background thread.** Strands' `__call__`
|
|
13
|
+
is sync-on-the-surface but internally uses `run_async` (a fresh
|
|
14
|
+
ThreadPoolExecutor + asyncio.run per call). If we evaluate the judge
|
|
15
|
+
synchronously after `super().__call__()` returns, the agent caller
|
|
16
|
+
blocks on the judge round-trip (2–8s for Gemini preview models with
|
|
17
|
+
`response_schema`). To the user it looks like the agent "stops" after
|
|
18
|
+
producing output. We fire-and-forget the eval onto a bounded executor,
|
|
19
|
+
re-attaching the captured OTel context in the worker so the span lands
|
|
20
|
+
under the same agent invocation. Pre-input stays sync — semantically
|
|
21
|
+
must run before the agent fires.
|
|
22
|
+
|
|
23
|
+
- **Snapshot the eval text BEFORE submitting.** The eval text builder
|
|
24
|
+
reads `agent.messages`, which Strands mutates on subsequent calls.
|
|
25
|
+
Without a snapshot, a fast follow-up prompt would race the bg thread
|
|
26
|
+
and the judge would see a half-mutated history.
|
|
9
27
|
"""
|
|
10
28
|
|
|
11
29
|
from __future__ import annotations
|
|
12
30
|
|
|
31
|
+
import atexit
|
|
13
32
|
import logging
|
|
33
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
14
34
|
from datetime import datetime, timezone
|
|
15
35
|
from typing import Any, Iterable, List
|
|
16
36
|
|
|
37
|
+
from opentelemetry import context as otel_context
|
|
17
38
|
from opentelemetry import trace
|
|
18
39
|
|
|
19
40
|
from tracectrl.guardrails.guardrail import Guardrail, _model_identifier
|
|
@@ -22,6 +43,36 @@ logger = logging.getLogger(__name__)
|
|
|
22
43
|
|
|
23
44
|
|
|
24
45
|
_REGISTRATION_SPAN_NAME = "tracectrl.guardrail.registered"
|
|
46
|
+
_INVOCATION_SPAN_NAME = "tracectrl.agent.invocation"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Bounded executor for post-output evals. max_workers=2 keeps memory + FD
|
|
50
|
+
# usage tight; the queue is unbounded but in practice a single agent caller
|
|
51
|
+
# can't outpace 2 workers by much (judge calls are 1–8s each). Daemon
|
|
52
|
+
# threads so a hung judge doesn't block process exit. atexit shuts it down
|
|
53
|
+
# with a short grace period so short scripts still flush their spans.
|
|
54
|
+
_eval_executor: ThreadPoolExecutor | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _get_eval_executor() -> ThreadPoolExecutor:
|
|
58
|
+
global _eval_executor
|
|
59
|
+
if _eval_executor is None:
|
|
60
|
+
_eval_executor = ThreadPoolExecutor(
|
|
61
|
+
max_workers=2,
|
|
62
|
+
thread_name_prefix="tracectrl-guardrail-eval",
|
|
63
|
+
)
|
|
64
|
+
atexit.register(_shutdown_eval_executor)
|
|
65
|
+
return _eval_executor
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _shutdown_eval_executor() -> None:
|
|
69
|
+
global _eval_executor
|
|
70
|
+
if _eval_executor is not None:
|
|
71
|
+
# wait=True so a script that runs `agent(...)` then exits still
|
|
72
|
+
# flushes the eval span. Workers are bounded, so worst case we
|
|
73
|
+
# wait one judge round-trip per pending eval.
|
|
74
|
+
_eval_executor.shutdown(wait=True)
|
|
75
|
+
_eval_executor = None
|
|
25
76
|
|
|
26
77
|
|
|
27
78
|
def _emit_registration_span(agent_id: str, agent_name: str, guardrail: Guardrail) -> None:
|
|
@@ -132,32 +183,54 @@ def wrap_agent_with_guardrails(agent: Any, guardrails: Iterable[Guardrail]) -> A
|
|
|
132
183
|
a_id = getattr(self, "_tracectrl_agent_id", None)
|
|
133
184
|
a_name = getattr(self, "_tracectrl_agent_name", None)
|
|
134
185
|
|
|
135
|
-
|
|
136
|
-
user_input = _extract_input(args, kwargs)
|
|
137
|
-
if user_input is not None:
|
|
138
|
-
for g in pre:
|
|
139
|
-
try:
|
|
140
|
-
g.evaluate(user_input, agent_id=a_id, agent_name=a_name)
|
|
141
|
-
except Exception: # noqa: BLE001
|
|
142
|
-
logger.exception("guardrail %s raised during pre_input eval", g.name)
|
|
143
|
-
|
|
144
|
-
response = super(GuardedAgent, self).__call__(*args, **kwargs)
|
|
145
|
-
|
|
146
|
-
if post:
|
|
147
|
-
# The agent's final response is often a terse status summary
|
|
148
|
-
# ("Payment workflow complete.") that hides the actual content
|
|
149
|
-
# we need to screen — tool inputs/outputs, OCR'd text from
|
|
150
|
-
# session context, etc. Pull the full message history off the
|
|
151
|
-
# Strands agent so the judge sees the COMPLETE picture, not just
|
|
152
|
-
# the synthesized summary.
|
|
153
|
-
output_text = _build_eval_text(self, response)
|
|
154
|
-
for g in post:
|
|
155
|
-
try:
|
|
156
|
-
g.evaluate(output_text, agent_id=a_id, agent_name=a_name)
|
|
157
|
-
except Exception: # noqa: BLE001 — never break the agent
|
|
158
|
-
logger.exception("guardrail %s raised during post_output eval", g.name)
|
|
186
|
+
tracer = trace.get_tracer("tracectrl.guardrails")
|
|
159
187
|
|
|
160
|
-
|
|
188
|
+
# Outer span wraps the entire invocation. Strands' run_async copies
|
|
189
|
+
# the OTel context into its worker thread, so the invoke_agent /
|
|
190
|
+
# chat / tool spans Strands creates become children of this span.
|
|
191
|
+
# The bg-thread post-eval re-attaches this same context, so its
|
|
192
|
+
# eval span also lands here. Net result: one tidy tree per call.
|
|
193
|
+
with tracer.start_as_current_span(_INVOCATION_SPAN_NAME) as invocation_span:
|
|
194
|
+
if a_id:
|
|
195
|
+
invocation_span.set_attribute("tracectrl.agent.id", a_id)
|
|
196
|
+
if a_name:
|
|
197
|
+
invocation_span.set_attribute("tracectrl.agent.name", a_name)
|
|
198
|
+
|
|
199
|
+
if pre:
|
|
200
|
+
user_input = _extract_input(args, kwargs)
|
|
201
|
+
if user_input is not None:
|
|
202
|
+
for g in pre:
|
|
203
|
+
try:
|
|
204
|
+
g.evaluate(user_input, agent_id=a_id, agent_name=a_name)
|
|
205
|
+
except Exception: # noqa: BLE001
|
|
206
|
+
logger.exception("guardrail %s raised during pre_input eval", g.name)
|
|
207
|
+
|
|
208
|
+
response = super(GuardedAgent, self).__call__(*args, **kwargs)
|
|
209
|
+
|
|
210
|
+
if post:
|
|
211
|
+
# Snapshot the eval text NOW, while we still hold the lock
|
|
212
|
+
# of the current invocation — a follow-up agent call would
|
|
213
|
+
# mutate `agent.messages` and racing the bg worker against
|
|
214
|
+
# that mutation is what produces the "memory leak between
|
|
215
|
+
# agents" symptom users have reported.
|
|
216
|
+
output_text = _build_eval_text(self, response)
|
|
217
|
+
captured_ctx = otel_context.get_current()
|
|
218
|
+
for g in post:
|
|
219
|
+
try:
|
|
220
|
+
_get_eval_executor().submit(
|
|
221
|
+
_run_post_eval_bg,
|
|
222
|
+
g,
|
|
223
|
+
output_text,
|
|
224
|
+
a_id,
|
|
225
|
+
a_name,
|
|
226
|
+
captured_ctx,
|
|
227
|
+
)
|
|
228
|
+
except Exception: # noqa: BLE001 — never break the agent
|
|
229
|
+
logger.exception(
|
|
230
|
+
"guardrail %s failed to submit post_output eval", g.name
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return response
|
|
161
234
|
|
|
162
235
|
GuardedAgent = type(
|
|
163
236
|
f"_TraceCtrlGuarded_{cls.__name__}",
|
|
@@ -172,6 +245,29 @@ def wrap_agent_with_guardrails(agent: Any, guardrails: Iterable[Guardrail]) -> A
|
|
|
172
245
|
return agent
|
|
173
246
|
|
|
174
247
|
|
|
248
|
+
def _run_post_eval_bg(
|
|
249
|
+
guardrail: Guardrail,
|
|
250
|
+
output_text: str,
|
|
251
|
+
agent_id: str | None,
|
|
252
|
+
agent_name: str | None,
|
|
253
|
+
captured_ctx: otel_context.Context,
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Run a single post-output guardrail evaluation on a background thread.
|
|
256
|
+
|
|
257
|
+
Re-attaches the OTel context captured at submit time so the eval span
|
|
258
|
+
parents under the same agent invocation, not under whatever happened to
|
|
259
|
+
be active in this worker. Errors are logged, never raised — this thread
|
|
260
|
+
has no caller to surface them to.
|
|
261
|
+
"""
|
|
262
|
+
token = otel_context.attach(captured_ctx)
|
|
263
|
+
try:
|
|
264
|
+
guardrail.evaluate(output_text, agent_id=agent_id, agent_name=agent_name)
|
|
265
|
+
except Exception: # noqa: BLE001
|
|
266
|
+
logger.exception("guardrail %s raised during post_output eval", guardrail.name)
|
|
267
|
+
finally:
|
|
268
|
+
otel_context.detach(token)
|
|
269
|
+
|
|
270
|
+
|
|
175
271
|
def register_guardrails(agent: Any, guardrails: Iterable[Guardrail]) -> None:
|
|
176
272
|
"""Emit registration spans without wrapping the agent.
|
|
177
273
|
|
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
"""Judge LLM invocation with structured output parsing.
|
|
2
|
-
|
|
3
|
-
Uses Bedrock's `converse` API directly via boto3. Strands' BedrockModel
|
|
4
|
-
wraps the same API, but its public surface is async (`structured_output`)
|
|
5
|
-
and the public method names have shifted between versions, so binding to
|
|
6
|
-
boto3 directly is far more stable. We extract `model_id` + `region` from
|
|
7
|
-
the BedrockModel object and call `bedrock-runtime.converse` ourselves.
|
|
8
|
-
|
|
9
|
-
On parse failure we re-prompt once; a second failure is treated as
|
|
10
|
-
`pass=true` (a broken judge must not spam violation alerts).
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
import json
|
|
16
|
-
import logging
|
|
17
|
-
from dataclasses import dataclass
|
|
18
|
-
from typing import Any, Optional
|
|
19
|
-
|
|
20
|
-
logger = logging.getLogger(__name__)
|
|
21
|
-
|
|
22
|
-
# Single tool the judge is forced to call. Schema matches the PRD exactly.
|
|
23
|
-
_JUDGE_TOOL_NAME = "record_decision"
|
|
24
|
-
_JUDGE_TOOL_SCHEMA = {
|
|
25
|
-
"type": "object",
|
|
26
|
-
"properties": {
|
|
27
|
-
"pass": {
|
|
28
|
-
"type": "boolean",
|
|
29
|
-
"description": "true if the output satisfies the guardrail; false if it violates.",
|
|
30
|
-
},
|
|
31
|
-
"reason": {
|
|
32
|
-
"type": "string",
|
|
33
|
-
"description": "One-sentence explanation of the decision.",
|
|
34
|
-
},
|
|
35
|
-
"evidence": {
|
|
36
|
-
"type": ["string", "null"],
|
|
37
|
-
"description": "Verbatim snippet that triggered a fail; null if pass.",
|
|
38
|
-
},
|
|
39
|
-
},
|
|
40
|
-
"required": ["pass", "reason"],
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
@dataclass
|
|
45
|
-
class JudgeResult:
|
|
46
|
-
passed: bool
|
|
47
|
-
reason: str
|
|
48
|
-
evidence: Optional[str]
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def invoke_judge(judge_llm: Any, prompt: str) -> JudgeResult:
|
|
52
|
-
"""Invoke the judge twice at most; second parse failure → conservative pass."""
|
|
53
|
-
last_err: Optional[Exception] = None
|
|
54
|
-
for attempt in (1, 2):
|
|
55
|
-
try:
|
|
56
|
-
raw = _call_model(judge_llm, prompt, attempt=attempt)
|
|
57
|
-
parsed = _parse_judge_response(raw)
|
|
58
|
-
return parsed
|
|
59
|
-
except Exception as exc: # noqa: BLE001 — broad on purpose; retry once
|
|
60
|
-
last_err = exc
|
|
61
|
-
logger.warning("judge attempt %d failed: %s", attempt, exc)
|
|
62
|
-
continue
|
|
63
|
-
|
|
64
|
-
logger.warning(
|
|
65
|
-
"guardrail judge failed to produce valid JSON twice; defaulting to pass (last error: %s)",
|
|
66
|
-
last_err,
|
|
67
|
-
)
|
|
68
|
-
return JudgeResult(passed=True, reason="judge parse failed; defaulted to pass", evidence=None)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def _resolve_bedrock_model(judge_llm: Any) -> tuple[str, str]:
|
|
72
|
-
"""Pull (model_id, region) from a Strands BedrockModel or from explicit config."""
|
|
73
|
-
# Strands BedrockModel stores config in `_config` / `get_config()`.
|
|
74
|
-
config: dict = {}
|
|
75
|
-
if hasattr(judge_llm, "get_config"):
|
|
76
|
-
try:
|
|
77
|
-
cfg = judge_llm.get_config()
|
|
78
|
-
if isinstance(cfg, dict):
|
|
79
|
-
config = cfg
|
|
80
|
-
except Exception: # noqa: BLE001
|
|
81
|
-
pass
|
|
82
|
-
if not config and hasattr(judge_llm, "config"):
|
|
83
|
-
c = judge_llm.config
|
|
84
|
-
if isinstance(c, dict):
|
|
85
|
-
config = c
|
|
86
|
-
model_id = (
|
|
87
|
-
config.get("model_id")
|
|
88
|
-
or getattr(judge_llm, "model_id", None)
|
|
89
|
-
or getattr(judge_llm, "model", None)
|
|
90
|
-
)
|
|
91
|
-
region = (
|
|
92
|
-
config.get("region_name")
|
|
93
|
-
or getattr(judge_llm, "region_name", None)
|
|
94
|
-
or "us-east-1"
|
|
95
|
-
)
|
|
96
|
-
if not model_id:
|
|
97
|
-
raise RuntimeError(f"could not extract model_id from judge_llm: {type(judge_llm).__name__}")
|
|
98
|
-
return model_id, region
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def _call_model(judge_llm: Any, prompt: str, *, attempt: int) -> Any:
|
|
102
|
-
"""Call Bedrock converse with tool-use forcing the JSON schema.
|
|
103
|
-
|
|
104
|
-
boto3 is bundled with every AWS Lambda / Strands deploy; importing it lazily
|
|
105
|
-
here keeps the SDK's import-time footprint clean.
|
|
106
|
-
"""
|
|
107
|
-
import boto3
|
|
108
|
-
|
|
109
|
-
model_id, region = _resolve_bedrock_model(judge_llm)
|
|
110
|
-
|
|
111
|
-
system = (
|
|
112
|
-
"You are an automated guardrail judge. You MUST call the "
|
|
113
|
-
f"`{_JUDGE_TOOL_NAME}` tool with your decision. Do not answer in plain text."
|
|
114
|
-
)
|
|
115
|
-
if attempt == 2:
|
|
116
|
-
system += " Your previous response was not valid JSON; respond by calling the tool exactly."
|
|
117
|
-
|
|
118
|
-
client = boto3.client("bedrock-runtime", region_name=region)
|
|
119
|
-
response = client.converse(
|
|
120
|
-
modelId=model_id,
|
|
121
|
-
messages=[{"role": "user", "content": [{"text": prompt}]}],
|
|
122
|
-
system=[{"text": system}],
|
|
123
|
-
toolConfig={
|
|
124
|
-
"tools": [{
|
|
125
|
-
"toolSpec": {
|
|
126
|
-
"name": _JUDGE_TOOL_NAME,
|
|
127
|
-
"description": "Record the guardrail pass/fail decision.",
|
|
128
|
-
"inputSchema": {"json": _JUDGE_TOOL_SCHEMA},
|
|
129
|
-
}
|
|
130
|
-
}],
|
|
131
|
-
# `any` forces the model to call SOME tool; combined with a single
|
|
132
|
-
# tool in the list this guarantees we get our schema back.
|
|
133
|
-
"toolChoice": {"any": {}},
|
|
134
|
-
},
|
|
135
|
-
)
|
|
136
|
-
return response
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def _parse_judge_response(raw: Any) -> JudgeResult:
|
|
140
|
-
"""Extract the structured decision from a Bedrock converse response."""
|
|
141
|
-
payload: Optional[dict] = None
|
|
142
|
-
|
|
143
|
-
# Bedrock converse response shape: {output: {message: {content: [{toolUse: {input: {...}}}]}}}
|
|
144
|
-
if isinstance(raw, dict):
|
|
145
|
-
output = raw.get("output") or {}
|
|
146
|
-
message = output.get("message") if isinstance(output, dict) else None
|
|
147
|
-
if isinstance(message, dict):
|
|
148
|
-
for block in message.get("content", []) or []:
|
|
149
|
-
if isinstance(block, dict) and "toolUse" in block:
|
|
150
|
-
payload = block["toolUse"].get("input")
|
|
151
|
-
break
|
|
152
|
-
if payload is None:
|
|
153
|
-
# Some intermediaries flatten this — try direct keys.
|
|
154
|
-
payload = raw.get("input") or raw.get("toolUse", {}).get("input")
|
|
155
|
-
|
|
156
|
-
# Plain text fallback — try to find a JSON object in the string.
|
|
157
|
-
if payload is None:
|
|
158
|
-
text = _stringify(raw)
|
|
159
|
-
payload = _extract_json_object(text)
|
|
160
|
-
|
|
161
|
-
if not isinstance(payload, dict):
|
|
162
|
-
raise ValueError(f"could not extract JSON object from judge response: {raw!r}")
|
|
163
|
-
|
|
164
|
-
if "pass" not in payload or "reason" not in payload:
|
|
165
|
-
raise ValueError(f"judge JSON missing required keys: {payload!r}")
|
|
166
|
-
|
|
167
|
-
return JudgeResult(
|
|
168
|
-
passed=bool(payload["pass"]),
|
|
169
|
-
reason=str(payload.get("reason", "")),
|
|
170
|
-
evidence=(str(payload["evidence"]) if payload.get("evidence") else None),
|
|
171
|
-
)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def _stringify(raw: Any) -> str:
|
|
175
|
-
if isinstance(raw, str):
|
|
176
|
-
return raw
|
|
177
|
-
if isinstance(raw, dict):
|
|
178
|
-
return json.dumps(raw)
|
|
179
|
-
text = getattr(raw, "text", None)
|
|
180
|
-
if isinstance(text, str):
|
|
181
|
-
return text
|
|
182
|
-
return str(raw)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
def _extract_json_object(text: str) -> Optional[dict]:
|
|
186
|
-
"""Find the first balanced top-level JSON object in `text`."""
|
|
187
|
-
start = text.find("{")
|
|
188
|
-
while start != -1:
|
|
189
|
-
depth = 0
|
|
190
|
-
for i in range(start, len(text)):
|
|
191
|
-
ch = text[i]
|
|
192
|
-
if ch == "{":
|
|
193
|
-
depth += 1
|
|
194
|
-
elif ch == "}":
|
|
195
|
-
depth -= 1
|
|
196
|
-
if depth == 0:
|
|
197
|
-
candidate = text[start : i + 1]
|
|
198
|
-
try:
|
|
199
|
-
obj = json.loads(candidate)
|
|
200
|
-
if isinstance(obj, dict):
|
|
201
|
-
return obj
|
|
202
|
-
except json.JSONDecodeError:
|
|
203
|
-
break
|
|
204
|
-
start = text.find("{", start + 1)
|
|
205
|
-
return None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|