humanbound-firewall 0.3.2__tar.gz → 0.3.4__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 (57) hide show
  1. {humanbound_firewall-0.3.2/src/humanbound_firewall.egg-info → humanbound_firewall-0.3.4}/PKG-INFO +3 -3
  2. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/pyproject.toml +3 -3
  3. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/__init__.py +1 -1
  4. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/firewall.py +14 -2
  5. humanbound_firewall-0.3.4/src/humanbound_firewall/firewall_judge.py +245 -0
  6. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/integrations/langchain.py +70 -11
  7. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/llm/azureopenai.py +30 -23
  8. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/llm/models.py +5 -0
  9. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/llm/openai.py +77 -29
  10. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4/src/humanbound_firewall.egg-info}/PKG-INFO +3 -3
  11. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall.egg-info/requires.txt +2 -2
  12. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_langchain_middleware.py +171 -0
  13. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_llm_providers.py +102 -7
  14. humanbound_firewall-0.3.4/tests/test_verdict_extraction.py +292 -0
  15. humanbound_firewall-0.3.2/src/humanbound_firewall/firewall_judge.py +0 -140
  16. humanbound_firewall-0.3.2/tests/test_verdict_extraction.py +0 -96
  17. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/LICENSE +0 -0
  18. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/NOTICE +0 -0
  19. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/README.md +0 -0
  20. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/setup.cfg +0 -0
  21. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/cache.py +0 -0
  22. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/config.py +0 -0
  23. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/guard.py +0 -0
  24. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/hbfw.py +0 -0
  25. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/integrations/__init__.py +0 -0
  26. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/judge.py +0 -0
  27. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/llm/__init__.py +0 -0
  28. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/llm/claude.py +0 -0
  29. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/llm/gemini.py +0 -0
  30. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/metrics.py +0 -0
  31. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/models.py +0 -0
  32. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/prompts/judge.txt +0 -0
  33. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/prompts/judge_ingest.txt +0 -0
  34. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/prompts/judge_recall.txt +0 -0
  35. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/py.typed +0 -0
  36. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall/session.py +0 -0
  37. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall.egg-info/SOURCES.txt +0 -0
  38. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall.egg-info/dependency_links.txt +0 -0
  39. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/src/humanbound_firewall.egg-info/top_level.txt +0 -0
  40. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_adapt_to.py +0 -0
  41. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_classes.py +0 -0
  42. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_config.py +0 -0
  43. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_conformance.py +0 -0
  44. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_detectors.py +0 -0
  45. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_firewall.py +0 -0
  46. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_guard.py +0 -0
  47. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_hbfw.py +0 -0
  48. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_import_time.py +0 -0
  49. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_inspect.py +0 -0
  50. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_judge.py +0 -0
  51. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_judge_classes.py +0 -0
  52. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_judge_explanation.py +0 -0
  53. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_langchain_log_background.py +0 -0
  54. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_metrics.py +0 -0
  55. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_models.py +0 -0
  56. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_policy_tools.py +0 -0
  57. {humanbound_firewall-0.3.2 → humanbound_firewall-0.3.4}/tests/test_session.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: humanbound-firewall
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: Multi-tier firewall for AI agents — blocks prompt injections, jailbreaks, and scope violations; fast local tiers screen every request, only uncertain cases reach an LLM judge
5
5
  Author-email: Humanbound <hello@humanbound.ai>
6
6
  Maintainer-email: Demetris Gerogiannis <hello@humanbound.ai>, Kostas Siabanis <hello@humanbound.ai>
@@ -34,7 +34,7 @@ Requires-Dist: pyyaml>=6.0
34
34
  Requires-Dist: pydantic>=2.0
35
35
  Requires-Dist: requests>=2.28
36
36
  Provides-Extra: openai
37
- Requires-Dist: openai>=1.0; extra == "openai"
37
+ Requires-Dist: openai>=1.58; extra == "openai"
38
38
  Provides-Extra: anthropic
39
39
  Requires-Dist: anthropic>=0.30; extra == "anthropic"
40
40
  Provides-Extra: gemini
@@ -45,7 +45,7 @@ Requires-Dist: transformers>=4.40; extra == "tier1"
45
45
  Requires-Dist: protobuf>=3.20; extra == "tier1"
46
46
  Requires-Dist: sentencepiece>=0.1; extra == "tier1"
47
47
  Provides-Extra: all
48
- Requires-Dist: openai>=1.0; extra == "all"
48
+ Requires-Dist: openai>=1.58; extra == "all"
49
49
  Requires-Dist: anthropic>=0.30; extra == "all"
50
50
  Requires-Dist: google-generativeai>=0.3; extra == "all"
51
51
  Requires-Dist: torch>=2.0; extra == "all"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "humanbound-firewall"
7
- version = "0.3.2"
7
+ version = "0.3.4"
8
8
  description = "Multi-tier firewall for AI agents — blocks prompt injections, jailbreaks, and scope violations; fast local tiers screen every request, only uncertain cases reach an LLM judge"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -49,7 +49,7 @@ dependencies = [
49
49
  ]
50
50
 
51
51
  [project.optional-dependencies]
52
- openai = ["openai>=1.0"]
52
+ openai = ["openai>=1.58"]
53
53
  anthropic = ["anthropic>=0.30"]
54
54
  gemini = ["google-generativeai>=0.3"]
55
55
  tier1 = [
@@ -59,7 +59,7 @@ tier1 = [
59
59
  "sentencepiece>=0.1",
60
60
  ]
61
61
  all = [
62
- "openai>=1.0",
62
+ "openai>=1.58",
63
63
  "anthropic>=0.30",
64
64
  "google-generativeai>=0.3",
65
65
  "torch>=2.0",
@@ -17,7 +17,7 @@ from __future__ import annotations
17
17
  import sys
18
18
  from typing import TYPE_CHECKING
19
19
 
20
- __version__ = "0.3.2"
20
+ __version__ = "0.3.4"
21
21
 
22
22
  from .models import CLASSES, VERDICT_MAP, AgentConfig, Category, EvalResult, Turn, Verdict
23
23
 
@@ -220,9 +220,17 @@ class Firewall:
220
220
  fail: str = "open",
221
221
  on_decision=None,
222
222
  withheld_template: str | None = None,
223
+ on_refusal: str = "block",
223
224
  ):
224
225
  """Deployment choices (classes, mode, fail, on_decision, withheld_template) are the
225
- Guard's; `mode` defaults to the policy file's settings.mode. See guard.Guard."""
226
+ Guard's; `mode` defaults to the policy file's settings.mode. See guard.Guard.
227
+
228
+ `on_refusal` is what a Tier 3 judge that refuses to evaluate the payload means (Claude's
229
+ `refusal` stop, an Azure OpenAI content filter): "block" (the default: the provider's
230
+ safety layer judged the text harmful) or "review" (uncertain, decided by `fail`)."""
231
+ if on_refusal not in ("block", "review"):
232
+ raise ValueError(f"Unknown on_refusal '{on_refusal}'. Expected 'block' or 'review'.")
233
+ self._on_refusal = on_refusal
226
234
  self._config = config
227
235
  self._streamer = streamer
228
236
  self._ensemble = ensemble
@@ -253,6 +261,7 @@ class Firewall:
253
261
  fail: str = "open",
254
262
  on_decision=None,
255
263
  withheld_template: str | None = None,
264
+ on_refusal: str = "block",
256
265
  ) -> "Firewall":
257
266
  """Create a Firewall from configuration.
258
267
 
@@ -271,6 +280,8 @@ class Firewall:
271
280
  on_decision: Observe-only callback fired with every Decision.
272
281
  withheld_template: Text replacing a withheld payload; may use {category},
273
282
  {explanation}, {cls}, {boundary}.
283
+ on_refusal: block | review — what a judge that refuses to evaluate the payload
284
+ means (a provider safety refusal or content filter).
274
285
  """
275
286
  config = load_config(config_path)
276
287
 
@@ -321,6 +332,7 @@ class Firewall:
321
332
  fail=fail,
322
333
  on_decision=on_decision,
323
334
  withheld_template=withheld_template,
335
+ on_refusal=on_refusal,
324
336
  )
325
337
 
326
338
  def evaluate(
@@ -746,7 +758,7 @@ class Firewall:
746
758
  from .firewall_judge import stream_and_extract_verdict
747
759
 
748
760
  return stream_and_extract_verdict(
749
- self._streamer, system_prompt, user_prompt, timeout, session_id
761
+ self._streamer, system_prompt, user_prompt, timeout, session_id, self._on_refusal
750
762
  )
751
763
 
752
764
  def reload_config(self, config_path: str | Path):
@@ -0,0 +1,245 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2024-2026 Humanbound
3
+ """Streaming LLM judge — the verdict is the letter the reply starts with.
4
+
5
+ The judge answers `<letter> <explanation>`. The letter is read off the start of the reply and only
6
+ counts as a standalone word: the first word is read to its boundary before it is trusted, so "C"
7
+ followed by "ertainly" is not a restriction verdict, and a letter inside the payload's own text
8
+ (a judge captured into echoing it) never becomes one. A reply that opens with anything else — a
9
+ preamble, a continuation of the payload — is a protocol violation: the judge is asked once more,
10
+ tersely, and if the retry does not start with a letter either the result is "no verdict", which a
11
+ fail-closed deployment withholds. The first reply is dropped as soon as it is known to hold no
12
+ verdict, so the retry starts at once; the retry alone is read to its end.
13
+
14
+ A judge whose provider refuses to evaluate the payload — Claude stopping with `refusal`, an Azure
15
+ OpenAI content filter, an OpenAI `refusal` — gives no letter either, but that is not doubt: the
16
+ provider's own safety layer judged the text harmful. The firewall's job is to stop exactly such
17
+ text, so by default a refusal is a block (`on_refusal="block"`). A deployment that would rather
18
+ treat it as an uncertain verdict, decided by its fail mode, sets `on_refusal="review"`.
19
+ """
20
+
21
+ import threading
22
+
23
+ from .firewall import _extract_token
24
+ from .models import VERDICT_MAP, Category, EvalResult, Verdict
25
+
26
+ _WRAPPERS = " \t\r\n*#`\"'_>-"
27
+ _RETRY_SUFFIX = "\n\nReply with the verdict letter only (P, A, B, C or D), then the explanation."
28
+ ON_REFUSAL = ("block", "review")
29
+ # What a provider's content filter says when it rejects the request itself (Azure OpenAI's
30
+ # prompt filter, OpenAI's moderation), as opposed to any other error.
31
+ _FILTER_MARKERS = ("content_filter", "content management policy", "content_policy_violation")
32
+
33
+
34
+ def _read_verdict(head: str, ended: bool = False):
35
+ """Read the verdict off the start of the reply.
36
+
37
+ Returns (letter, remainder) once the reply is known to start with a standalone verdict
38
+ letter, (None, None) while more text is needed to know, and (None, "") once it is clear
39
+ the reply does not start with one.
40
+ """
41
+ i = 0
42
+ while i < len(head) and head[i] in _WRAPPERS:
43
+ i += 1
44
+ if i >= len(head):
45
+ return (None, "") if ended else (None, None) # only wrappers so far
46
+ first = head[i]
47
+ if not first.isalpha() or first.upper() not in VERDICT_MAP:
48
+ return None, "" # the reply starts with something else
49
+ if i + 1 >= len(head):
50
+ # The word is not finished: wait for its boundary (a one-letter reply resolves at the end).
51
+ return (first.upper(), "") if ended else (None, None)
52
+ if head[i + 1].isalpha():
53
+ return None, "" # the letter is the start of a word
54
+ rest = head[i + 1 :].lstrip(_WRAPPERS.replace("-", "").replace(">", ""))
55
+ return first.upper(), rest
56
+
57
+
58
+ def _refusal(chunk) -> str | None:
59
+ """The reason, when a streamed chunk says the provider refused to answer; else None.
60
+
61
+ Anthropic ends a refused message with `stop_reason: "refusal"` (and a `stop_details` category);
62
+ OpenAI and Azure OpenAI send a `refusal` text or finish with `content_filter`. Only real strings
63
+ count, so a chunk without these fields is never mistaken for a refusal.
64
+ """
65
+ if getattr(chunk, "type", None) == "message_delta":
66
+ delta = getattr(chunk, "delta", None)
67
+ if getattr(delta, "stop_reason", None) == "refusal":
68
+ details = getattr(delta, "stop_details", None) or getattr(chunk, "stop_details", None)
69
+ category = getattr(details, "category", None)
70
+ return (
71
+ f"provider refusal: {category}" if isinstance(category, str) else "provider refusal"
72
+ )
73
+ return None
74
+ choices = getattr(chunk, "choices", None)
75
+ if isinstance(choices, (list, tuple)) and choices:
76
+ choice = choices[0]
77
+ refusal = getattr(getattr(choice, "delta", None), "refusal", None)
78
+ if isinstance(refusal, str) and refusal.strip():
79
+ return f"provider refusal: {refusal.strip()[:120]}"
80
+ if getattr(choice, "finish_reason", None) == "content_filter":
81
+ return "provider content filter"
82
+ return None
83
+
84
+
85
+ def _filtered(error: BaseException) -> str | None:
86
+ """The reason, when an error is the provider's content filter rejecting the request; else None."""
87
+ code = getattr(error, "code", None)
88
+ text = str(error)
89
+ if code == "content_filter" or any(marker in text for marker in _FILTER_MARKERS):
90
+ return "provider content filter"
91
+ return None
92
+
93
+
94
+ def stream_and_extract_verdict(
95
+ streamer,
96
+ system_prompt: str,
97
+ user_prompt: str,
98
+ timeout: int,
99
+ session_id: str,
100
+ on_refusal: str = "block",
101
+ ) -> EvalResult:
102
+ """Stream the judge's reply; return as soon as the verdict letter is known."""
103
+ if on_refusal not in ON_REFUSAL:
104
+ raise ValueError(f"Unknown on_refusal '{on_refusal}'. Expected one of {ON_REFUSAL}.")
105
+ result_holder: list[EvalResult | None] = [None]
106
+ decision_event = threading.Event()
107
+ explanation_ready = threading.Event()
108
+
109
+ def stream_once(user_p: str, read_to_end: bool = False):
110
+ """One judge call. Returns (letter, explanation_parts, head, refusal): letter None = no
111
+ verdict; refusal names why the provider refused, when it did. A first reply that holds
112
+ no verdict is dropped where that is known, so the retry is not made to wait out a
113
+ preamble; only the retry is read to its end (`read_to_end`), to catch a late refusal."""
114
+ head, parts, letter, no_verdict = "", [], None, False
115
+ stream = streamer.ping(
116
+ system_p=system_prompt, user_p=user_p, max_tokens=1024, temperature=0.0
117
+ )
118
+ for chunk in stream:
119
+ if letter is None and (no_verdict or not head.strip()):
120
+ # A refusal counts while the reply holds no verdict: before the judge has said
121
+ # anything, and after what it has said is known not to be a verdict letter — a
122
+ # preamble answers nothing, so the refusal is the only signal there is. It does
123
+ # not count while a letter is still resolving ("P" before its boundary) or once
124
+ # one has been read: a filter that cuts an explanation short leaves the verdict.
125
+ refused = _refusal(chunk)
126
+ if refused:
127
+ return None, [], head, refused
128
+ try:
129
+ token = _extract_token(chunk)
130
+ except Exception as e:
131
+ # A chunk that cannot be read is not an answer. Gemini raises here when its
132
+ # safety filter blocked the reply, so the refusal is the chunk itself.
133
+ if letter is not None:
134
+ break # the verdict is already in; stop reading the explanation
135
+ return None, [], head, f"provider refusal: unreadable chunk ({type(e).__name__})"
136
+ if no_verdict or token is None:
137
+ # The reply does not start with a letter. Nothing more is read from it, but the
138
+ # stream is followed to its end: a refusal still decides, and arrives last.
139
+ continue
140
+ if letter is not None:
141
+ parts.append(token)
142
+ continue
143
+ head += token
144
+ found, remainder = _read_verdict(head)
145
+ if found is None:
146
+ if remainder is not None:
147
+ no_verdict = True # decided: no verdict, but a refusal may still follow
148
+ if not read_to_end:
149
+ # Leave at once: the retry must not wait out a long preamble, or a
150
+ # judge that only thinks out loud costs the caller its whole timeout.
151
+ return None, [], head, None
152
+ continue
153
+ letter = found
154
+ _decide(letter)
155
+ if remainder.strip():
156
+ parts.append(remainder)
157
+ if letter is None and not no_verdict:
158
+ found, remainder = _read_verdict(head, ended=True)
159
+ if found is not None:
160
+ letter = found
161
+ _decide(letter)
162
+ return letter, parts, head, None
163
+
164
+ def _decide(letter):
165
+ verdict, category = VERDICT_MAP[letter]
166
+ result_holder[0] = EvalResult(
167
+ verdict=verdict,
168
+ category=category,
169
+ raw_letter=letter,
170
+ session_id=session_id,
171
+ _explanation_ready=explanation_ready,
172
+ )
173
+ decision_event.set()
174
+
175
+ def _refused(reason: str):
176
+ """The provider refused to evaluate the payload: a block by default."""
177
+ if on_refusal == "block":
178
+ result_holder[0] = EvalResult(
179
+ verdict=Verdict.BLOCK,
180
+ category=Category.VIOLATION,
181
+ explanation=f"Judge refused to evaluate the payload ({reason}); treated as an attack.",
182
+ session_id=session_id,
183
+ )
184
+ else:
185
+ result_holder[0] = EvalResult(
186
+ verdict=Verdict.REVIEW,
187
+ category=Category.UNCERTAIN,
188
+ explanation=f"Judge refused to evaluate the payload ({reason}).",
189
+ session_id=session_id,
190
+ )
191
+ decision_event.set()
192
+
193
+ def _run():
194
+ try:
195
+ letter, parts, head, refused = stream_once(user_prompt)
196
+ if letter is None and not refused and head.strip():
197
+ # A protocol violation: ask once more, tersely.
198
+ letter, parts, head2, refused = stream_once(
199
+ user_prompt + _RETRY_SUFFIX, read_to_end=True
200
+ )
201
+ head = head2 if letter is None else head
202
+ except Exception as e:
203
+ if result_holder[0] is None:
204
+ filtered = _filtered(e)
205
+ if filtered:
206
+ _refused(filtered)
207
+ else:
208
+ result_holder[0] = EvalResult(
209
+ verdict=Verdict.REVIEW,
210
+ category=Category.UNCERTAIN,
211
+ explanation=f"Stream error: {str(e)[:200]}",
212
+ session_id=session_id,
213
+ )
214
+ decision_event.set()
215
+ explanation_ready.set()
216
+ return
217
+
218
+ if result_holder[0] is not None:
219
+ explanation = "".join(parts).strip()
220
+ result_holder[0].explanation = explanation.lstrip(" \t\r\n*#`\"'_").strip()
221
+ elif refused:
222
+ _refused(refused)
223
+ else:
224
+ said = head.strip().replace("\n", " ")[:120]
225
+ explanation = (
226
+ f"Judge did not start with a verdict: '{said}'"
227
+ if said
228
+ else "Judge returned no verdict."
229
+ )
230
+ result_holder[0] = EvalResult(
231
+ verdict=Verdict.REVIEW,
232
+ category=Category.UNCERTAIN,
233
+ explanation=explanation,
234
+ session_id=session_id,
235
+ )
236
+ decision_event.set()
237
+ explanation_ready.set()
238
+
239
+ thread = threading.Thread(target=_run, daemon=True)
240
+ thread.start()
241
+
242
+ if not decision_event.wait(timeout=timeout):
243
+ raise TimeoutError("Evaluation timed out waiting for verdict.")
244
+
245
+ return result_holder[0]
@@ -34,6 +34,7 @@ Usage:
34
34
  from __future__ import annotations
35
35
 
36
36
  import asyncio
37
+ import json
37
38
  import logging
38
39
  import threading
39
40
  from collections import deque
@@ -215,8 +216,8 @@ class HumanboundFirewallMiddleware(AgentMiddleware):
215
216
  return written # a later model call in the same run: the turn was judged already
216
217
  if self.firewall.guard.mode_for("request") == "off":
217
218
  return written
218
- text = messages[-1].content
219
- if not isinstance(text, str):
219
+ text = _text(messages[-1].content)
220
+ if text is None: # no text to judge (an image alone)
220
221
  return written
221
222
  window = self._window(messages[:-1])
222
223
  if self._background("request"): # log mode: a request is never rejected, so do not wait
@@ -277,13 +278,15 @@ class HumanboundFirewallMiddleware(AgentMiddleware):
277
278
  return not self._log_blocking and self.firewall.guard.mode_for(cls) == "log"
278
279
 
279
280
  def _screen(self, request, result):
280
- if not isinstance(result, ToolMessage) or not isinstance(result.content, str):
281
+ if not isinstance(result, ToolMessage):
282
+ return result
283
+ payload = _text(result.content)
284
+ if payload is None: # no text to judge (an image or file alone)
281
285
  return result
282
286
  name = request.tool_call.get("name", "")
283
287
  cls = self.firewall.config.class_of(name)
284
288
  description = str(getattr(getattr(request, "tool", None), "description", "") or "")
285
289
  # Everything the judge sees is fixed here, when the tool returned.
286
- payload = result.content
287
290
  window = self._window(request.state.get("messages", []))
288
291
  boundary = {
289
292
  **self._tool_boundary(name, description),
@@ -411,11 +414,10 @@ class HumanboundFirewallMiddleware(AgentMiddleware):
411
414
  window = []
412
415
  for message in messages:
413
416
  for kind, role in _ROLES:
414
- if isinstance(message, kind) and isinstance(message.content, str):
415
- if message.content:
416
- window.append(
417
- {"role": role, "content": message.content[: self._history_chars]}
418
- )
417
+ if isinstance(message, kind):
418
+ text = _text(message.content)
419
+ if text:
420
+ window.append({"role": role, "content": text[: self._history_chars]})
419
421
  return window[-self._history_messages :]
420
422
 
421
423
 
@@ -425,6 +427,63 @@ def _tool_name(tool) -> str:
425
427
 
426
428
  def _last_human(messages) -> str:
427
429
  for message in reversed(list(messages)):
428
- if isinstance(message, HumanMessage) and isinstance(message.content, str):
429
- return message.content
430
+ if isinstance(message, HumanMessage):
431
+ text = _text(message.content)
432
+ if text is not None:
433
+ return text
430
434
  return ""
435
+
436
+
437
+ # The block types that carry no text to judge: the model reads them as media, and their payload
438
+ # is bytes or a URL. Everything else is screened, whatever its type — LangChain keeps document,
439
+ # search_result, json, custom_tool_call_output and file blocks as tool content, a human turn can
440
+ # carry text-plain, and each one reaches the model as text. Listing what to skip rather than what
441
+ # to read means a block type added later is screened by default: over-screening is the safe
442
+ # failure, a block that slips past is not.
443
+ _MEDIA_BLOCKS = frozenset({"image", "image_url", "audio", "video"})
444
+ # Dropped from a block's JSON form: raw media bytes, which carry no text and would swamp the judge.
445
+ _BINARY_FIELDS = frozenset({"base64", "data_base64", "bytes"})
446
+
447
+
448
+ def _text(content) -> str | None:
449
+ """The text a message carries, whether its content is a string or a list of content blocks.
450
+
451
+ LangChain allows both (`str | list[str | dict]`): multimodal messages and MCP tool results with
452
+ several parts or an image arrive as a list. Plain strings and `{"type": "text"}` blocks are
453
+ joined with newlines, so text split across blocks is judged as one payload. Any other block —
454
+ a document, a search result, JSON, a custom tool's output — is judged as its JSON form, since
455
+ the model reads the text inside it. Pure media blocks are skipped. None when nothing is left.
456
+ """
457
+ if isinstance(content, str):
458
+ return content
459
+ if not isinstance(content, list):
460
+ return None
461
+ parts = []
462
+ for block in content:
463
+ if isinstance(block, str):
464
+ parts.append(block)
465
+ elif isinstance(block, dict):
466
+ if block.get("type") in _MEDIA_BLOCKS:
467
+ continue
468
+ if block.get("type") == "text" and isinstance(block.get("text"), str):
469
+ parts.append(block["text"])
470
+ else:
471
+ parts.append(_block_json(block))
472
+ parts = [p for p in parts if p]
473
+ return "\n".join(parts) if parts else None
474
+
475
+
476
+ def _block_json(block: dict) -> str:
477
+ """A non-text block as JSON for the judge, with raw media bytes dropped at any depth."""
478
+
479
+ def strip(value):
480
+ if isinstance(value, dict):
481
+ return {k: strip(v) for k, v in value.items() if k not in _BINARY_FIELDS}
482
+ if isinstance(value, list):
483
+ return [strip(v) for v in value]
484
+ return value
485
+
486
+ try:
487
+ return json.dumps(strip(block), ensure_ascii=False, default=str)
488
+ except (TypeError, ValueError):
489
+ return str(block)
@@ -8,9 +8,10 @@ from os import getenv
8
8
  import requests
9
9
 
10
10
  # Same parameter handling as the OpenAI provider (see there for the reasoning): the modern
11
- # `max_completion_tokens` for every deployment, and `temperature` retried away once on the 400
12
- # that names it. Azure deployment names are arbitrary, so a model-name list could never work here.
13
- from .openai import _models_without_temperature, _rejects_temperature, _sampling_params
11
+ # `max_completion_tokens` for every deployment, and `temperature` / `reasoning_effort` dropped on
12
+ # the 400 that names them. Azure deployment names are arbitrary, so a model-name list could never
13
+ # work here — the drop-and-remember is keyed on the deployment name just the same.
14
+ from .openai import _note_rejected, _sampling_params
14
15
 
15
16
  ALLOWED_MAX_OUT_TOKENS = 4096
16
17
  DEFAULT_MAX_OUT_TOKENS = 2048
@@ -37,6 +38,7 @@ class LLMStreamer:
37
38
  azure_endpoint=integ.get("endpoint", ""),
38
39
  )
39
40
  self.model = integ["model"]
41
+ self.reasoning_effort = integ.get("reasoning_effort")
40
42
 
41
43
  def ping(
42
44
  self, system_p, user_p, max_tokens=DEFAULT_MAX_OUT_TOKENS, temperature=DEFAULT_TEMPERATURE
@@ -46,21 +48,26 @@ class LLMStreamer:
46
48
  {"role": "system", "content": system_p},
47
49
  {"role": "user", "content": user_p},
48
50
  ]
49
- params = _sampling_params(self.model, max_tokens, temperature)
50
- try:
51
- return self.__client.chat.completions.create(
52
- model=self.model, messages=messages, timeout=LLM_PING_TIMEOUT, stream=True, **params
53
- )
54
- except Exception as e:
55
- if "temperature" not in params or not (
56
- getattr(e, "status_code", None) == 400 and _rejects_temperature(str(e))
57
- ):
58
- raise
59
- _models_without_temperature.add(self.model)
60
- params.pop("temperature")
61
- return self.__client.chat.completions.create(
62
- model=self.model, messages=messages, timeout=LLM_PING_TIMEOUT, stream=True, **params
63
- )
51
+ params = _sampling_params(self.model, max_tokens, temperature, self.reasoning_effort)
52
+ while True:
53
+ try:
54
+ return self.__client.chat.completions.create(
55
+ model=self.model,
56
+ messages=messages,
57
+ timeout=LLM_PING_TIMEOUT,
58
+ stream=True,
59
+ **params,
60
+ )
61
+ except Exception as e:
62
+ drop = (
63
+ _note_rejected(self.model, str(e), getattr(e, "param", None)) & params.keys()
64
+ if getattr(e, "status_code", None) == 400
65
+ else set()
66
+ )
67
+ if not drop:
68
+ raise
69
+ for name in drop:
70
+ params.pop(name)
64
71
 
65
72
 
66
73
  class LLMPinger:
@@ -81,7 +88,9 @@ class LLMPinger:
81
88
  {"role": "system", "content": system_p},
82
89
  {"role": "user", "content": user_p},
83
90
  ],
84
- **_sampling_params(integ["model"], max_tokens, temperature),
91
+ **_sampling_params(
92
+ integ["model"], max_tokens, temperature, integ.get("reasoning_effort")
93
+ ),
85
94
  },
86
95
  timeout=LLM_PING_TIMEOUT,
87
96
  )
@@ -109,10 +118,8 @@ class LLMPinger:
109
118
  continue
110
119
  raise Exception("502/Rate limit error.")
111
120
  elif resp.status_code == 400:
112
- model = self._provider["integration"]["model"]
113
- if model not in _models_without_temperature and _rejects_temperature(resp.text):
114
- _models_without_temperature.add(model)
115
- continue # retry once, now without temperature
121
+ if _note_rejected(self._provider["integration"]["model"], resp.text):
122
+ continue # retry; the next request omits the param(s) the deployment rejected
116
123
  raise Exception(f"502/Inappropriate content ({resp.text}).")
117
124
  else:
118
125
  raise Exception(f"502/Error pinging LLM - {resp.status_code}/{resp.text}")
@@ -19,6 +19,11 @@ class ProviderIntegration(BaseModel):
19
19
  model: str
20
20
  endpoint: str | None = None
21
21
  api_version: str | None = None
22
+ # Reasoning effort for the judge, when the model is a reasoning model that accepts it
23
+ # (e.g. OpenAI "minimal" | "low" | "medium" | "high"). Left None, no effort is sent and
24
+ # the model's own default applies. A model that rejects the parameter has it dropped on
25
+ # the first 400 and remembered, the same way an unsupported temperature is.
26
+ reasoning_effort: str | None = None
22
27
 
23
28
 
24
29
  class Provider(BaseModel):