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.
Files changed (22) hide show
  1. {tracectrl-0.2.0 → tracectrl-0.3.1}/PKG-INFO +2 -2
  2. {tracectrl-0.2.0 → tracectrl-0.3.1}/README.md +1 -1
  3. {tracectrl-0.2.0 → tracectrl-0.3.1}/pyproject.toml +1 -1
  4. {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/__init__.py +1 -1
  5. tracectrl-0.3.1/src/tracectrl/guardrails/judge.py +417 -0
  6. {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/guardrails/strands_hook.py +121 -25
  7. tracectrl-0.2.0/src/tracectrl/guardrails/judge.py +0 -205
  8. {tracectrl-0.2.0 → tracectrl-0.3.1}/.gitignore +0 -0
  9. {tracectrl-0.2.0 → tracectrl-0.3.1}/LICENSE +0 -0
  10. {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/_tui.py +0 -0
  11. {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/agent_tagging.py +0 -0
  12. {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/cli.py +0 -0
  13. {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/config.py +0 -0
  14. {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/context.py +0 -0
  15. {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/exporter.py +0 -0
  16. {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/guardrails/__init__.py +0 -0
  17. {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/guardrails/guardrail.py +0 -0
  18. {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/inference.py +0 -0
  19. {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/processor.py +0 -0
  20. {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/protector.py +0 -0
  21. {tracectrl-0.2.0 → tracectrl-0.3.1}/src/tracectrl/schema.py +0 -0
  22. {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.2.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tracectrl"
7
- version = "0.2.0"
7
+ version = "0.3.1"
8
8
  description = "TraceCtrl SDK — agentic AI security observability"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -5,7 +5,7 @@
5
5
  from pkgutil import extend_path
6
6
  __path__ = extend_path(__path__, __name__)
7
7
 
8
- __version__ = "0.2.0"
8
+ __version__ = "0.3.1"
9
9
 
10
10
  from tracectrl.config import configure # noqa: F401
11
11
  from tracectrl.context import ingress # noqa: F401
@@ -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
- if pre:
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
- return response
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