integrable-firewall 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ """Connect an AI agent built in code to its firewall.
2
+
3
+ The firewall screens every user message, every tool call before it runs and
4
+ every tool result before the model reads it. One adapter protects one agent::
5
+
6
+ from integrable_firewall.langchain import FirewallMiddleware
7
+ agent = create_agent(model, tools, middleware=[FirewallMiddleware(agent="support-widget")])
8
+
9
+ Adapters: ``integrable_firewall.langchain``, ``integrable_firewall.openai_agents``,
10
+ ``integrable_firewall.adk``, ``integrable_firewall.pydantic_ai``. Each imports
11
+ its framework only when it is imported itself.
12
+ """
13
+
14
+ from ._client import (
15
+ TIMEOUT_SECONDS,
16
+ UNREACHABLE,
17
+ Firewall,
18
+ FirewallAgentMismatch,
19
+ FirewallBlocked,
20
+ FirewallConfigError,
21
+ FirewallError,
22
+ Verdict,
23
+ key_variable,
24
+ )
25
+ from ._version import __version__
26
+
27
+ __all__ = [
28
+ "Firewall",
29
+ "Verdict",
30
+ "FirewallBlocked",
31
+ "FirewallError",
32
+ "FirewallConfigError",
33
+ "FirewallAgentMismatch",
34
+ "UNREACHABLE",
35
+ "TIMEOUT_SECONDS",
36
+ "key_variable",
37
+ "__version__",
38
+ ]
@@ -0,0 +1,652 @@
1
+ """The core: settings, the three events over HTTP, the answer read, failing closed.
2
+
3
+ Wire protocol: ``packages/PROTOCOL.md`` in the firewall repository. Standard
4
+ library only (``urllib``), Python 3.9 or later. Every request runs in a thread
5
+ of its own with a total deadline of 9.5 s, so the async entry points never
6
+ block the event loop and a server that trickles its answer cannot hold a step
7
+ past the deadline.
8
+
9
+ The key is read from the ``key`` option or the environment, checked against
10
+ the agent name, and sent only as the ``Authorization`` header to the
11
+ configured URL: never logged, never put in an error message, never forwarded
12
+ on a redirect (redirects are not followed at all).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import dataclasses
19
+ import json
20
+ import logging
21
+ import math
22
+ import os
23
+ import re
24
+ import threading
25
+ import urllib.error
26
+ import urllib.parse
27
+ import urllib.request
28
+ import uuid
29
+ from typing import Any, Dict, Mapping, Optional, Tuple
30
+
31
+ from ._version import __version__
32
+
33
+ __all__ = [
34
+ "Firewall",
35
+ "Verdict",
36
+ "FirewallBlocked",
37
+ "FirewallError",
38
+ "FirewallConfigError",
39
+ "FirewallAgentMismatch",
40
+ "UNREACHABLE",
41
+ "TIMEOUT_SECONDS",
42
+ "key_variable",
43
+ ]
44
+
45
+ PACKAGE = "integrable-firewall"
46
+ USER_AGENT = f"integrable-firewall-python/{__version__}"
47
+
48
+ #: The total time one request may take; the server's own budget is 10 s.
49
+ TIMEOUT_SECONDS = 9.5
50
+
51
+ #: What the model (or the app, for a user message) reads when the firewall
52
+ #: cannot be reached or answers with an error. Quoted from PROTOCOL.md.
53
+ UNREACHABLE: Dict[str, str] = {
54
+ "UserPromptSubmit": "Firewall: the firewall could not be reached, so this message was not processed.",
55
+ "PreToolUse": "Firewall: the firewall could not be reached, so this step did not run.",
56
+ "PostToolUse": "Firewall: the firewall could not be reached, so this result was withheld.",
57
+ }
58
+
59
+ # A stop the firewall answered without a reason (the server always sends one).
60
+ _STOPPED: Dict[str, str] = {
61
+ "UserPromptSubmit": "Firewall: this message was blocked.",
62
+ "PreToolUse": "Firewall: this step was stopped.",
63
+ "PostToolUse": "Firewall: this result was withheld.",
64
+ }
65
+
66
+ _EVENTS = ("UserPromptSubmit", "PreToolUse", "PostToolUse")
67
+ _AGENT_RE = re.compile(r"^[a-z0-9._-]{1,40}$")
68
+ _KEY_RE = re.compile(r"^fwa_[0-9a-f]{64}_(?P<agent>[a-z0-9._-]{1,40})$") # server/agent-keys.mjs AGENT_KEY_RE
69
+ _MAX_ANSWER_BYTES = 32 * 1024 * 1024
70
+
71
+ log = logging.getLogger("integrable_firewall")
72
+
73
+
74
+ class FirewallError(Exception):
75
+ """A configuration or usage error of the firewall adapter (never a decision)."""
76
+
77
+
78
+ class FirewallConfigError(FirewallError, ValueError):
79
+ """The adapter's settings are wrong: the agent name, the key or the URL."""
80
+
81
+
82
+ class FirewallAgentMismatch(FirewallError):
83
+ """One adapter was used for a second agent: one adapter protects one agent."""
84
+
85
+
86
+ class FirewallBlocked(Exception):
87
+ """The firewall blocked the user's message, which did not reach the model.
88
+
89
+ ``reason`` is the firewall's reason ("Firewall: ..."), for the app to answer
90
+ its user with or to replace with its own words. ``unreachable`` is true when
91
+ the block is the fail-closed answer to a firewall that could not be reached.
92
+ """
93
+
94
+ def __init__(self, reason: str, verdict: Optional["Verdict"] = None) -> None:
95
+ super().__init__(reason)
96
+ self.reason = reason
97
+ self.verdict = verdict
98
+
99
+ @property
100
+ def unreachable(self) -> bool:
101
+ return bool(self.verdict is not None and self.verdict.failed_closed)
102
+
103
+
104
+ @dataclasses.dataclass(frozen=True)
105
+ class Verdict:
106
+ """The firewall's answer, normalised.
107
+
108
+ ``action`` is one of:
109
+
110
+ - ``"pass"``: go on unchanged.
111
+ - ``"block"``: the message does not reach the model, the tool does not run,
112
+ or the result is withheld; the model (or the app) reads ``reason``.
113
+ - ``"replace"`` (tool results only): the model reads ``value`` in place of
114
+ the tool's result.
115
+
116
+ ``error`` is set when the verdict is the fail-closed answer to a firewall
117
+ that could not be reached or answered with an error; it says why (never the
118
+ key) and is what the adapter logs.
119
+ """
120
+
121
+ action: str
122
+ reason: Optional[str] = None
123
+ value: Any = None
124
+ context: Optional[str] = None
125
+ error: Optional[str] = None
126
+ status: Optional[int] = None
127
+
128
+ @property
129
+ def passed(self) -> bool:
130
+ return self.action == "pass"
131
+
132
+ @property
133
+ def blocked(self) -> bool:
134
+ return self.action == "block"
135
+
136
+ @property
137
+ def replaced(self) -> bool:
138
+ return self.action == "replace"
139
+
140
+ @property
141
+ def failed_closed(self) -> bool:
142
+ return self.error is not None
143
+
144
+ def model_text(self) -> str:
145
+ """The text the model reads in place of the tool's result."""
146
+ if self.action == "replace":
147
+ return value_text(self.value)
148
+ return self.reason or ""
149
+
150
+
151
+ def key_variable(agent: str) -> str:
152
+ """The environment variable that holds one agent's key.
153
+
154
+ ``support-widget`` -> ``FIREWALL_KEY_SUPPORT_WIDGET``.
155
+ """
156
+ return "FIREWALL_KEY_" + re.sub(r"[^A-Z0-9]", "_", agent.upper())
157
+
158
+
159
+ def _env(name: str) -> Optional[str]:
160
+ value = os.environ.get(name)
161
+ if value is None:
162
+ return None
163
+ value = value.strip()
164
+ return value or None
165
+
166
+
167
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
168
+ """Never follow a redirect: the key goes to the configured URL only."""
169
+
170
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[override]
171
+ return None
172
+
173
+
174
+ class _Timeout(Exception):
175
+ pass
176
+
177
+
178
+ class Firewall:
179
+ """A client for one agent's firewall.
180
+
181
+ ``Firewall(agent="support-widget")`` reads the URL from ``FIREWALL_URL`` and
182
+ the key from ``FIREWALL_KEY_SUPPORT_WIDGET``, then ``FIREWALL_KEY``; the
183
+ ``url`` and ``key`` options come first. A key made for another agent is a
184
+ :class:`FirewallConfigError` here, before anything is sent. A missing URL is
185
+ not an error: every call fails closed and one warning names the variable.
186
+
187
+ The adapters (``integrable_firewall.langchain``, ``.openai_agents``,
188
+ ``.adk``, ``.pydantic_ai``) build on this class; it can also be used
189
+ directly for a framework without an adapter.
190
+ """
191
+
192
+ def __init__(
193
+ self,
194
+ agent: str,
195
+ *,
196
+ url: Optional[str] = None,
197
+ key: Optional[str] = None,
198
+ framework: Optional[str] = None,
199
+ timeout: float = TIMEOUT_SECONDS,
200
+ ) -> None:
201
+ if not isinstance(agent, str) or not _AGENT_RE.match(agent) or not agent.strip("."):
202
+ raise FirewallConfigError(
203
+ f"integrable_firewall: the agent name {agent!r} is not valid: use 1 to 40 lowercase "
204
+ "letters, digits, '.', '_' or '-', the name the firewall knows the agent by "
205
+ "(for example 'support-widget')."
206
+ )
207
+ self.agent = agent
208
+ self.framework = framework
209
+ try:
210
+ timeout = float(timeout)
211
+ except (TypeError, ValueError):
212
+ raise FirewallConfigError("integrable_firewall: timeout must be a number of seconds.") from None
213
+ if not timeout > 0:
214
+ raise FirewallConfigError("integrable_firewall: timeout must be more than 0 seconds.")
215
+ self._timeout = min(timeout, TIMEOUT_SECONDS)
216
+
217
+ resolved_url = url.strip() if isinstance(url, str) and url.strip() else _env("FIREWALL_URL")
218
+ self.url: Optional[str] = resolved_url.rstrip("/") if resolved_url else None
219
+ if self.url is not None:
220
+ parts = urllib.parse.urlsplit(self.url)
221
+ if parts.scheme not in ("http", "https") or not parts.hostname:
222
+ where = "the url option" if url else "FIREWALL_URL"
223
+ raise FirewallConfigError(
224
+ f"integrable_firewall: {where} must be the firewall's http or https address "
225
+ f"(for example https://example.cloudfront.net), not {self.url!r}."
226
+ )
227
+ if parts.query or parts.fragment:
228
+ raise FirewallConfigError(
229
+ "integrable_firewall: the firewall's address takes no query or fragment."
230
+ )
231
+
232
+ variable = key_variable(agent)
233
+ agent_key, shared_key = _env(variable), _env("FIREWALL_KEY")
234
+ resolved_key: Optional[str] = None
235
+ source: Optional[str] = None
236
+ if isinstance(key, str) and key.strip():
237
+ resolved_key, source = key.strip(), "the key option"
238
+ elif agent_key is not None:
239
+ resolved_key, source = agent_key, variable
240
+ elif shared_key is not None:
241
+ resolved_key, source = shared_key, "FIREWALL_KEY"
242
+ if resolved_key is not None:
243
+ match = _KEY_RE.match(resolved_key)
244
+ if match is None:
245
+ raise FirewallConfigError(
246
+ f"integrable_firewall: the key from {source} is not a firewall agent key "
247
+ "(fwa_<64 hex>_<agent name>). Use the key the firewall made for "
248
+ f"'{agent}', placed in {variable}."
249
+ )
250
+ if match.group("agent") != agent:
251
+ raise FirewallConfigError(
252
+ f"integrable_firewall: the key from {source} is for the agent "
253
+ f"'{match.group('agent')}', but this adapter protects '{agent}'. One adapter "
254
+ f"protects one agent: use the key made for '{agent}', placed in {variable}."
255
+ )
256
+ self._key = resolved_key
257
+ self._key_source = source
258
+ self._key_variable = variable
259
+
260
+ self._endpoint = f"{self.url}/hook/{agent}" if self.url else None
261
+ self._opener = urllib.request.build_opener(_NoRedirect())
262
+ self._pin_lock = threading.Lock()
263
+ self._pinned: Optional[Tuple[Any, str]] = None
264
+
265
+ if self.url is None:
266
+ log.warning(
267
+ "integrable_firewall: FIREWALL_URL is not set, so every step of the agent '%s' "
268
+ "fails closed until it is.",
269
+ agent,
270
+ )
271
+ elif self._key is not None and urllib.parse.urlsplit(self.url).scheme == "http" and not _is_loopback(self.url):
272
+ log.warning(
273
+ "integrable_firewall: the firewall's address for '%s' is plain http, so its key "
274
+ "travels unencrypted; use the https address.",
275
+ agent,
276
+ )
277
+
278
+ def __repr__(self) -> str:
279
+ return f"Firewall(agent={self.agent!r}, url={self.url!r}, key={'set' if self._key else 'none'})"
280
+
281
+ # ------------------------------------------------------------------ pinning
282
+
283
+ def pin(self, identity: Any, label: str) -> None:
284
+ """Bind this adapter to the first framework agent it sees.
285
+
286
+ ``identity`` is a name (compared by value) or an object (compared by
287
+ identity); ``label`` says what it is, for the error. Any other agent
288
+ raises :class:`FirewallAgentMismatch`: one adapter protects one agent.
289
+ """
290
+ with self._pin_lock:
291
+ if self._pinned is None:
292
+ self._pinned = (identity, label)
293
+ return
294
+ pinned, pinned_label = self._pinned
295
+ same = pinned == identity if isinstance(pinned, str) and isinstance(identity, str) else pinned is identity
296
+ if same:
297
+ return
298
+ raise FirewallAgentMismatch(
299
+ f"integrable_firewall: one adapter protects one agent. This adapter is for "
300
+ f"'{self.agent}' and protects {pinned_label}, but it was just used by {label}, so the "
301
+ f"firewall stopped there. Give {label} an adapter of its own, with its own agent name and key."
302
+ )
303
+
304
+ # ------------------------------------------------------------------ events
305
+
306
+ def user_message(self, prompt: str, *, session_id: Optional[str] = None) -> Verdict:
307
+ """Screen a new message from the agent's user (``UserPromptSubmit``)."""
308
+ return self._send("UserPromptSubmit", self._payload("UserPromptSubmit", session_id, prompt=to_text(prompt)))
309
+
310
+ def before_tool(
311
+ self,
312
+ tool_name: str,
313
+ tool_input: Any,
314
+ *,
315
+ session_id: Optional[str] = None,
316
+ tool_use_id: Optional[str] = None,
317
+ ) -> Verdict:
318
+ """Screen a tool call before it runs (``PreToolUse``)."""
319
+ return self._send("PreToolUse", self._tool_payload("PreToolUse", session_id, tool_name, tool_input, tool_use_id))
320
+
321
+ def after_tool(
322
+ self,
323
+ tool_name: str,
324
+ tool_input: Any,
325
+ tool_response: Any,
326
+ *,
327
+ session_id: Optional[str] = None,
328
+ tool_use_id: Optional[str] = None,
329
+ ) -> Verdict:
330
+ """Screen a tool's result before the model reads it (``PostToolUse``)."""
331
+ payload = self._tool_payload("PostToolUse", session_id, tool_name, tool_input, tool_use_id)
332
+ payload["tool_response"] = jsonable(tool_response)
333
+ return self._send("PostToolUse", payload)
334
+
335
+ async def auser_message(self, prompt: str, *, session_id: Optional[str] = None) -> Verdict:
336
+ return await self._asend("UserPromptSubmit", self._payload("UserPromptSubmit", session_id, prompt=to_text(prompt)))
337
+
338
+ async def abefore_tool(
339
+ self,
340
+ tool_name: str,
341
+ tool_input: Any,
342
+ *,
343
+ session_id: Optional[str] = None,
344
+ tool_use_id: Optional[str] = None,
345
+ ) -> Verdict:
346
+ return await self._asend("PreToolUse", self._tool_payload("PreToolUse", session_id, tool_name, tool_input, tool_use_id))
347
+
348
+ async def aafter_tool(
349
+ self,
350
+ tool_name: str,
351
+ tool_input: Any,
352
+ tool_response: Any,
353
+ *,
354
+ session_id: Optional[str] = None,
355
+ tool_use_id: Optional[str] = None,
356
+ ) -> Verdict:
357
+ payload = self._tool_payload("PostToolUse", session_id, tool_name, tool_input, tool_use_id)
358
+ payload["tool_response"] = jsonable(tool_response)
359
+ return await self._asend("PostToolUse", payload)
360
+
361
+ def check_user_message(self, prompt: str, *, session_id: Optional[str] = None) -> None:
362
+ """Like :meth:`user_message`, raising :class:`FirewallBlocked` on a block."""
363
+ verdict = self.user_message(prompt, session_id=session_id)
364
+ if not verdict.passed:
365
+ raise FirewallBlocked(verdict.reason or UNREACHABLE["UserPromptSubmit"], verdict)
366
+
367
+ async def acheck_user_message(self, prompt: str, *, session_id: Optional[str] = None) -> None:
368
+ verdict = await self.auser_message(prompt, session_id=session_id)
369
+ if not verdict.passed:
370
+ raise FirewallBlocked(verdict.reason or UNREACHABLE["UserPromptSubmit"], verdict)
371
+
372
+ # ------------------------------------------------------------------ internals
373
+
374
+ def _payload(self, event: str, session_id: Optional[str], **fields: Any) -> Dict[str, Any]:
375
+ client: Dict[str, Any] = {"package": PACKAGE, "version": __version__, "language": "python"}
376
+ if self.framework:
377
+ client["framework"] = self.framework
378
+ payload: Dict[str, Any] = {
379
+ "hook_event_name": event,
380
+ "session_id": str(session_id) if session_id else new_session_id(),
381
+ "firewall_client": client,
382
+ }
383
+ payload.update(fields)
384
+ return payload
385
+
386
+ def _tool_payload(
387
+ self, event: str, session_id: Optional[str], tool_name: str, tool_input: Any, tool_use_id: Optional[str]
388
+ ) -> Dict[str, Any]:
389
+ payload = self._payload(event, session_id, tool_name=str(tool_name), tool_input=as_object(tool_input))
390
+ if tool_use_id:
391
+ payload["tool_use_id"] = str(tool_use_id)
392
+ return payload
393
+
394
+ def _body(self, payload: Mapping[str, Any]) -> bytes:
395
+ return json.dumps(payload, ensure_ascii=True, allow_nan=False, separators=(",", ":")).encode("ascii")
396
+
397
+ def _post(self, body: bytes) -> Tuple[int, bytes]:
398
+ assert self._endpoint is not None
399
+ headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT}
400
+ if self._key:
401
+ headers["Authorization"] = f"Bearer {self._key}"
402
+ request = urllib.request.Request(self._endpoint, data=body, headers=headers, method="POST")
403
+ try:
404
+ with self._opener.open(request, timeout=self._timeout) as response:
405
+ return response.status, response.read(_MAX_ANSWER_BYTES + 1)
406
+ except urllib.error.HTTPError as error:
407
+ try:
408
+ error.close()
409
+ except Exception:
410
+ pass
411
+ return error.code, b""
412
+
413
+ def _send(self, event: str, payload: Dict[str, Any]) -> Verdict:
414
+ if self._endpoint is None:
415
+ return self._fail(event, "FIREWALL_URL is not set", warn=False)
416
+ try:
417
+ body = self._body(payload)
418
+ except (TypeError, ValueError) as error: # jsonable() leaves nothing unserialisable
419
+ return self._fail(event, f"the event could not be serialised ({type(error).__name__})")
420
+ try:
421
+ status, data = _call_with_deadline(lambda: self._post(body), self._timeout)
422
+ except _Timeout:
423
+ return self._fail(event, f"no answer within {self._timeout:g} s")
424
+ except Exception as error:
425
+ return self._fail(event, _describe(error))
426
+ return self._read(event, status, data)
427
+
428
+ async def _asend(self, event: str, payload: Dict[str, Any]) -> Verdict:
429
+ if self._endpoint is None:
430
+ return self._fail(event, "FIREWALL_URL is not set", warn=False)
431
+ try:
432
+ body = self._body(payload)
433
+ except (TypeError, ValueError) as error:
434
+ return self._fail(event, f"the event could not be serialised ({type(error).__name__})")
435
+ try:
436
+ status, data = await _acall_with_deadline(lambda: self._post(body), self._timeout)
437
+ except _Timeout:
438
+ return self._fail(event, f"no answer within {self._timeout:g} s")
439
+ except Exception as error:
440
+ return self._fail(event, _describe(error))
441
+ return self._read(event, status, data)
442
+
443
+ def _fail(self, event: str, why: str, *, status: Optional[int] = None, warn: bool = True) -> Verdict:
444
+ if warn:
445
+ log.warning("integrable_firewall: %s, so the %s of the agent '%s' failed closed.", why, _NOUN[event], self.agent)
446
+ return Verdict("block", reason=UNREACHABLE[event], error=why, status=status)
447
+
448
+ def _read(self, event: str, status: int, data: bytes) -> Verdict:
449
+ if status == 204:
450
+ return Verdict("pass", status=204)
451
+ if status in (401, 403):
452
+ if self._key is None:
453
+ hint = f"no key was found: set {self._key_variable}"
454
+ else:
455
+ hint = f"the key from {self._key_source} is wrong, replaced or not for '{self.agent}'"
456
+ return self._fail(event, f"the firewall refused the request (HTTP {status}): {hint}", status=status)
457
+ if status != 200:
458
+ return self._fail(event, f"the firewall answered HTTP {status}", status=status)
459
+ if len(data) > _MAX_ANSWER_BYTES:
460
+ return self._fail(event, "the firewall's answer was too large to read", status=status)
461
+ try:
462
+ body = json.loads(data.decode("utf-8"))
463
+ except (UnicodeDecodeError, ValueError):
464
+ return self._fail(event, "the firewall's answer was not JSON", status=status)
465
+ if not isinstance(body, dict):
466
+ return self._fail(event, "the firewall's answer was not a JSON object", status=status)
467
+ return read_answer(event, body)
468
+
469
+
470
+ _NOUN = {"UserPromptSubmit": "user message", "PreToolUse": "tool call", "PostToolUse": "tool result"}
471
+
472
+
473
+ def read_answer(event: str, body: Mapping[str, Any]) -> Verdict:
474
+ """Read a ``200`` body for one event. Anything unknown is the most restrictive outcome."""
475
+ if event not in _EVENTS:
476
+ raise ValueError(f"unknown event {event!r}")
477
+ hso = body.get("hookSpecificOutput")
478
+ hso = hso if isinstance(hso, Mapping) else {}
479
+ reason = _reason(body.get("reason"))
480
+
481
+ if event == "UserPromptSubmit":
482
+ return Verdict("block", reason=reason or _STOPPED[event], status=200)
483
+
484
+ if event == "PreToolUse":
485
+ # deny and ask both mean the tool does not run; anything else is read as deny.
486
+ return Verdict("block", reason=_reason(hso.get("permissionDecisionReason")) or reason or _STOPPED[event], status=200)
487
+
488
+ # PostToolUse
489
+ updated = hso.get("updatedToolOutput")
490
+ context = _reason(hso.get("additionalContext"))
491
+ if body.get("decision") == "block":
492
+ if updated is not None:
493
+ return Verdict("replace", reason=reason, value=updated, context=context, status=200)
494
+ return Verdict("block", reason=reason or _STOPPED[event], status=200)
495
+ if updated is not None:
496
+ return Verdict("replace", reason=reason, value=updated, context=context, status=200)
497
+ return Verdict("block", reason=reason or _STOPPED[event], status=200)
498
+
499
+
500
+ def _reason(value: Any) -> Optional[str]:
501
+ if isinstance(value, str) and value.strip():
502
+ return value.strip()
503
+ return None
504
+
505
+
506
+ def _describe(error: BaseException) -> str:
507
+ if isinstance(error, urllib.error.URLError):
508
+ inner = error.reason
509
+ if isinstance(inner, BaseException):
510
+ return f"the firewall could not be reached ({type(inner).__name__}: {inner})"
511
+ return f"the firewall could not be reached ({inner})"
512
+ if isinstance(error, TimeoutError) or type(error).__name__ == "timeout":
513
+ return "the firewall did not answer in time"
514
+ return f"the firewall could not be reached ({type(error).__name__}: {error})"
515
+
516
+
517
+ def _is_loopback(url: str) -> bool:
518
+ host = (urllib.parse.urlsplit(url).hostname or "").lower()
519
+ return host in ("localhost", "::1") or host.startswith("127.") or host.endswith(".localhost")
520
+
521
+
522
+ def _call_with_deadline(fn: Any, timeout: float) -> Any:
523
+ """Run ``fn`` in a daemon thread; give up after ``timeout`` seconds in all."""
524
+ outcome: Dict[str, Any] = {}
525
+ done = threading.Event()
526
+
527
+ def run() -> None:
528
+ try:
529
+ outcome["value"] = fn()
530
+ except BaseException as error: # handed to the caller
531
+ outcome["error"] = error
532
+ finally:
533
+ done.set()
534
+
535
+ threading.Thread(target=run, name="integrable-firewall-request", daemon=True).start()
536
+ if not done.wait(timeout):
537
+ raise _Timeout()
538
+ if "error" in outcome:
539
+ raise outcome["error"]
540
+ return outcome["value"]
541
+
542
+
543
+ async def _acall_with_deadline(fn: Any, timeout: float) -> Any:
544
+ """The same, awaited: the event loop keeps running while the request does."""
545
+ loop = asyncio.get_running_loop()
546
+ future: "asyncio.Future[Any]" = loop.create_future()
547
+
548
+ def settle(value: Any, error: Optional[BaseException]) -> None:
549
+ if future.done():
550
+ return
551
+ if error is not None:
552
+ future.set_exception(error)
553
+ else:
554
+ future.set_result(value)
555
+
556
+ def run() -> None:
557
+ try:
558
+ value, error = fn(), None
559
+ except BaseException as caught:
560
+ value, error = None, caught
561
+ try:
562
+ loop.call_soon_threadsafe(settle, value, error)
563
+ except RuntimeError: # the loop closed while the request ran
564
+ pass
565
+
566
+ threading.Thread(target=run, name="integrable-firewall-request", daemon=True).start()
567
+ try:
568
+ return await asyncio.wait_for(future, timeout)
569
+ except asyncio.TimeoutError:
570
+ raise _Timeout() from None
571
+
572
+
573
+ # ---------------------------------------------------------------------- values
574
+
575
+
576
+ def new_session_id() -> str:
577
+ """A random session id, for a run the framework gives no conversation id."""
578
+ return uuid.uuid4().hex
579
+
580
+
581
+ def as_object(value: Any) -> Dict[str, Any]:
582
+ """A tool's arguments as a JSON object; anything else is ``{"input": value}``."""
583
+ if isinstance(value, Mapping):
584
+ return {str(k): jsonable(v) for k, v in value.items()}
585
+ converted = jsonable(value)
586
+ if isinstance(converted, dict):
587
+ return converted
588
+ return {"input": converted}
589
+
590
+
591
+ def jsonable(value: Any, _depth: int = 0) -> Any:
592
+ """``value`` as JSON: a string stays a string, an object that cannot be
593
+ serialised is sent as its string form."""
594
+ if value is None or isinstance(value, (str, bool, int)):
595
+ return value
596
+ if isinstance(value, float):
597
+ return value if math.isfinite(value) else str(value)
598
+ if _depth > 64:
599
+ return str(value)
600
+ if isinstance(value, Mapping):
601
+ return {str(k): jsonable(v, _depth + 1) for k, v in value.items()}
602
+ if isinstance(value, (list, tuple, set, frozenset)):
603
+ return [jsonable(v, _depth + 1) for v in value]
604
+ if isinstance(value, (bytes, bytearray)):
605
+ return bytes(value).decode("utf-8", errors="replace")
606
+ dump = getattr(value, "model_dump", None) # pydantic v2 models
607
+ if callable(dump) and not isinstance(value, type):
608
+ try:
609
+ return jsonable(dump(mode="json"), _depth + 1)
610
+ except Exception:
611
+ pass
612
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
613
+ try:
614
+ return jsonable(dataclasses.asdict(value), _depth + 1)
615
+ except Exception:
616
+ pass
617
+ return str(value)
618
+
619
+
620
+ def value_text(value: Any) -> str:
621
+ """A replacement result as the text a model reads: a string as is, anything else as JSON."""
622
+ if isinstance(value, str):
623
+ return value
624
+ try:
625
+ return json.dumps(jsonable(value), ensure_ascii=False, allow_nan=False)
626
+ except (TypeError, ValueError):
627
+ return str(value)
628
+
629
+
630
+ def to_text(value: Any) -> str:
631
+ """Text for a prompt or a model-visible result: parts joined with newlines."""
632
+ if value is None:
633
+ return ""
634
+ if isinstance(value, str):
635
+ return value
636
+ if isinstance(value, (list, tuple)):
637
+ parts = [_part_text(part) for part in value]
638
+ return "\n".join(part for part in parts if part)
639
+ try:
640
+ return json.dumps(jsonable(value), ensure_ascii=False, allow_nan=False)
641
+ except (TypeError, ValueError):
642
+ return str(value)
643
+
644
+
645
+ def _part_text(part: Any) -> str:
646
+ if isinstance(part, str):
647
+ return part
648
+ if isinstance(part, Mapping):
649
+ text = part.get("text")
650
+ return text if isinstance(text, str) else ""
651
+ text = getattr(part, "text", None)
652
+ return text if isinstance(text, str) else ""
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"