langchain-openentity 0.1.0__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.
- langchain_openentity-0.1.0/.gitignore +14 -0
- langchain_openentity-0.1.0/PKG-INFO +39 -0
- langchain_openentity-0.1.0/README.md +18 -0
- langchain_openentity-0.1.0/langchain_openentity/__init__.py +8 -0
- langchain_openentity-0.1.0/langchain_openentity/core.py +266 -0
- langchain_openentity-0.1.0/langchain_openentity/guard.py +240 -0
- langchain_openentity-0.1.0/langchain_openentity/http.py +82 -0
- langchain_openentity-0.1.0/langchain_openentity/ledger.py +119 -0
- langchain_openentity-0.1.0/pyproject.toml +39 -0
- langchain_openentity-0.1.0/tests/conftest.py +42 -0
- langchain_openentity-0.1.0/tests/test_corpus.py +40 -0
- langchain_openentity-0.1.0/tests/test_ledger_and_routes.py +125 -0
- langchain_openentity-0.1.0/tests/test_middleware.py +173 -0
- langchain_openentity-0.1.0/tests/test_transports.py +127 -0
- langchain_openentity-0.1.0/tests/test_vectors.py +16 -0
- langchain_openentity-0.1.0/uv.lock +2536 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langchain-openentity
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: RuthlessSchemaGuard: LangChain middleware and HTTP transport guard that raises on malformed API and tool responses, with an optional signed ledger reporter.
|
|
5
|
+
Project-URL: Homepage, https://openentity.org
|
|
6
|
+
Project-URL: Documentation, https://oeis.dev
|
|
7
|
+
Project-URL: Source, https://github.com/moltbot-den/openentity
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: httpx
|
|
11
|
+
Requires-Dist: jsonschema>=4.18
|
|
12
|
+
Requires-Dist: langchain>=1.4
|
|
13
|
+
Requires-Dist: pydantic>=2
|
|
14
|
+
Provides-Extra: aiohttp
|
|
15
|
+
Requires-Dist: aiohttp; extra == 'aiohttp'
|
|
16
|
+
Provides-Extra: ledger
|
|
17
|
+
Requires-Dist: openentity>=0.4; extra == 'ledger'
|
|
18
|
+
Provides-Extra: requests
|
|
19
|
+
Requires-Dist: requests; extra == 'requests'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# langchain-openentity
|
|
23
|
+
|
|
24
|
+
RuthlessSchemaGuard for LangChain: middleware and HTTP transport guard that raise `OpenEntitySchemaViolation` on malformed tool and API responses, a dead-loop breaker, and an optional signed ledger reporter.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install langchain-openentity
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from langchain_openentity import RuthlessSchemaGuard
|
|
32
|
+
|
|
33
|
+
guard = RuthlessSchemaGuard()
|
|
34
|
+
guard.register("get_pulls", list[Pull])
|
|
35
|
+
guard.guard_http()
|
|
36
|
+
agent = create_agent(model, tools=[get_pulls], middleware=[guard]) # first in the list
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Full documentation, the rule table and the ledger settings: <https://github.com/moltbot-den/openentity/tree/main/integrations/langchain>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# langchain-openentity
|
|
2
|
+
|
|
3
|
+
RuthlessSchemaGuard for LangChain: middleware and HTTP transport guard that raise `OpenEntitySchemaViolation` on malformed tool and API responses, a dead-loop breaker, and an optional signed ledger reporter.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install langchain-openentity
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from langchain_openentity import RuthlessSchemaGuard
|
|
11
|
+
|
|
12
|
+
guard = RuthlessSchemaGuard()
|
|
13
|
+
guard.register("get_pulls", list[Pull])
|
|
14
|
+
guard.guard_http()
|
|
15
|
+
agent = create_agent(model, tools=[get_pulls], middleware=[guard]) # first in the list
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Full documentation, the rule table and the ledger settings: <https://github.com/moltbot-den/openentity/tree/main/integrations/langchain>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from .core import OpenEntityLoopDetected, OpenEntitySchemaViolation, inspect_response, scrub
|
|
2
|
+
from .guard import RuthlessSchemaGuard
|
|
3
|
+
from .ledger import DEFAULT_URL, LedgerReporter
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"RuthlessSchemaGuard", "OpenEntitySchemaViolation", "OpenEntityLoopDetected",
|
|
7
|
+
"LedgerReporter", "DEFAULT_URL", "inspect_response", "scrub",
|
|
8
|
+
]
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Response inspection shared by the middleware and the HTTP transport hooks.
|
|
2
|
+
|
|
3
|
+
The rules and rule names mirror the JS package (`@openentity/langchain`); `fixtures/` holds the
|
|
4
|
+
corpus both test suites run, so the two stay in step.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
from jsonschema import Draft202012Validator
|
|
17
|
+
from pydantic import TypeAdapter, ValidationError
|
|
18
|
+
|
|
19
|
+
EXCERPT = 500
|
|
20
|
+
SAMPLE = 2048
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OpenEntitySchemaViolation(Exception):
|
|
24
|
+
"""A response broke its contract. Never converted to a ToolMessage by this package."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, tool: str, errors: list[str], raw_excerpt: str = ""):
|
|
27
|
+
self.tool = tool
|
|
28
|
+
self.errors = list(errors)
|
|
29
|
+
self.raw_excerpt = scrub(raw_excerpt)[:EXCERPT]
|
|
30
|
+
self.rule = self.errors[0].split(":", 1)[0] if self.errors else ""
|
|
31
|
+
super().__init__(f"{tool}: " + "; ".join(self.errors[:3]))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class OpenEntityLoopDetected(Exception):
|
|
35
|
+
"""The same tool call with the same arguments failed `count` times in a row."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, tool: str, args_hash: str, count: int):
|
|
38
|
+
self.tool, self.args_hash, self.count = tool, args_hash, count
|
|
39
|
+
super().__init__(f"{tool} failed {count} times with identical arguments ({args_hash[:12]})")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---- scrubbing (rules ported from the ClawGRC/SMA secret scanner; same list in the JS package) ----
|
|
43
|
+
_FULL = [re.compile(p) for p in (
|
|
44
|
+
r"AKIA[0-9A-Z]{16}",
|
|
45
|
+
r"gh[pousr]_[A-Za-z0-9_]{36,255}",
|
|
46
|
+
r"xox[bpsa]-[0-9]+-[0-9A-Za-z-]+",
|
|
47
|
+
r"eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+",
|
|
48
|
+
r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|(?![\s\S]))",
|
|
49
|
+
r"(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|mssql)://[^:\s/]+:[^@\s]+@[^/\s\"']+",
|
|
50
|
+
r"[sr]k_(?:live|test)_[A-Za-z0-9]{20,}",
|
|
51
|
+
r"SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}",
|
|
52
|
+
r"[Bb]earer\s+[A-Za-z0-9._~+/=-]{16,}",
|
|
53
|
+
r"https?://[^/\s:@]+:[^/\s@]+@[^\s/\"']+",
|
|
54
|
+
)]
|
|
55
|
+
_KV = re.compile(
|
|
56
|
+
r"(\"?(?:api[_-]?key|api[_-]?secret|api[_-]?token|auth[_-]?token|access[_-]?token|refresh[_-]?token"
|
|
57
|
+
r"|secret[_-]?key|client[_-]?secret|password|passwd|token|secret)\"?\s*[=:]\s*\"?)[^\s\"',}&]{6,}",
|
|
58
|
+
re.I,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def scrub(text: str) -> str:
|
|
63
|
+
for rx in _FULL:
|
|
64
|
+
text = rx.sub("[REDACTED]", text)
|
|
65
|
+
return _KV.sub(lambda m: m.group(1) + "[REDACTED]", text)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---- schemas ----
|
|
69
|
+
def stable_json(v: Any) -> str:
|
|
70
|
+
return json.dumps(v, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class Schema:
|
|
75
|
+
"""A registered contract: a Pydantic model/type, or a JSON Schema dict."""
|
|
76
|
+
|
|
77
|
+
json_schema: dict
|
|
78
|
+
_check: Any
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def hash(self) -> str:
|
|
82
|
+
return hashlib.sha256(stable_json(self.json_schema).encode()).hexdigest()
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def declared(self) -> set[str]:
|
|
86
|
+
s, defs = self.json_schema, {**self.json_schema.get("$defs", {})}
|
|
87
|
+
if "$ref" in s:
|
|
88
|
+
name = s["$ref"].rsplit("/", 1)[-1]
|
|
89
|
+
s = defs.get(name) or self.json_schema.get("components", {}).get("schemas", {}).get(name, {})
|
|
90
|
+
keys = set(s.get("properties", {}))
|
|
91
|
+
for k in ("allOf", "anyOf", "oneOf"):
|
|
92
|
+
for sub in s.get(k, []):
|
|
93
|
+
keys |= set(sub.get("properties", {}))
|
|
94
|
+
return keys
|
|
95
|
+
|
|
96
|
+
def validate(self, parsed: Any, raw: str) -> list[str]:
|
|
97
|
+
return self._check(parsed, raw)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _nullable(node: Any) -> Any:
|
|
101
|
+
"""OpenAPI 3.0 `nullable: true` -> JSON Schema type union."""
|
|
102
|
+
if isinstance(node, list):
|
|
103
|
+
return [_nullable(n) for n in node]
|
|
104
|
+
if not isinstance(node, dict):
|
|
105
|
+
return node
|
|
106
|
+
out = {k: _nullable(v) for k, v in node.items()}
|
|
107
|
+
if out.get("nullable") is True and isinstance(out.get("type"), str):
|
|
108
|
+
out["type"] = [out["type"], "null"]
|
|
109
|
+
if isinstance(out.get("enum"), list) and None not in out["enum"]:
|
|
110
|
+
out["enum"] = [*out["enum"], None]
|
|
111
|
+
out.pop("nullable", None)
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def compile_schema(obj: Any) -> Schema:
|
|
116
|
+
if isinstance(obj, Schema):
|
|
117
|
+
return obj
|
|
118
|
+
if isinstance(obj, dict):
|
|
119
|
+
js = _nullable(obj)
|
|
120
|
+
v = Draft202012Validator(js)
|
|
121
|
+
|
|
122
|
+
def check(parsed: Any, _raw: str) -> list[str]:
|
|
123
|
+
errs = sorted(v.iter_errors(parsed), key=lambda e: list(map(str, e.absolute_path)))
|
|
124
|
+
return [f"schema: {'/'.join(map(str, e.absolute_path)) or '$'}: {e.message[:200]}" for e in errs[:10]]
|
|
125
|
+
|
|
126
|
+
return Schema(js, check)
|
|
127
|
+
ta = TypeAdapter(obj)
|
|
128
|
+
|
|
129
|
+
def check_py(_parsed: Any, raw: str) -> list[str]:
|
|
130
|
+
try:
|
|
131
|
+
ta.validate_json(raw, strict=True)
|
|
132
|
+
except ValidationError as e:
|
|
133
|
+
return [f"schema: {'/'.join(map(str, x['loc'])) or '$'}: {x['msg'][:200]}" for x in e.errors()[:10]]
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
return Schema(ta.json_schema(), check_py)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---- routes ----
|
|
140
|
+
def _pattern(url: str) -> re.Pattern:
|
|
141
|
+
sp = urlsplit(url)
|
|
142
|
+
path = sp.path.rstrip("/")
|
|
143
|
+
esc = lambda s: re.escape(s).replace(r"\*", "[^/]+") # noqa: E731
|
|
144
|
+
host = esc(sp.netloc.lower())
|
|
145
|
+
segs = []
|
|
146
|
+
for seg in path.split("/"):
|
|
147
|
+
if seg == "**":
|
|
148
|
+
segs.append(".*")
|
|
149
|
+
elif re.fullmatch(r"\{[^}]*\}", seg) or seg == "*":
|
|
150
|
+
segs.append("[^/]+")
|
|
151
|
+
else:
|
|
152
|
+
segs.append(esc(seg))
|
|
153
|
+
return re.compile(f"{sp.scheme.lower()}://{host}{'/'.join(segs)}/?", re.I)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Routes:
|
|
157
|
+
def __init__(self) -> None:
|
|
158
|
+
self._routes: list[tuple[str, re.Pattern, str, Schema]] = []
|
|
159
|
+
|
|
160
|
+
def add(self, route: str, schema: Schema) -> None:
|
|
161
|
+
method, _, url = route.strip().partition(" ")
|
|
162
|
+
if not url:
|
|
163
|
+
raise ValueError(f"route must look like 'GET https://host/path', got {route!r}")
|
|
164
|
+
self._routes.append((method.upper(), _pattern(url.strip()), urlsplit(url.strip()).path or "/", schema))
|
|
165
|
+
|
|
166
|
+
def match(self, method: str, url: str) -> tuple[Schema, str] | None:
|
|
167
|
+
sp = urlsplit(url)
|
|
168
|
+
bare = f"{sp.scheme}://{sp.netloc}{sp.path}"
|
|
169
|
+
for m, rx, path, schema in self._routes:
|
|
170
|
+
if m in ("*", method.upper()) and rx.fullmatch(bare):
|
|
171
|
+
return schema, path
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
_ID = re.compile(r"^(?:\d+|[0-9a-f]{8}-[0-9a-f-]{27}|[0-9a-f]{16,})$", re.I)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def path_template(url: str) -> str:
|
|
179
|
+
return "/".join("{id}" if _ID.match(s) else s for s in urlsplit(url).path.split("/")) or "/"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---- inspection ----
|
|
183
|
+
_WS = " \t\n\r"
|
|
184
|
+
_JSONP = re.compile(r"[A-Za-z_$][\w$.]*\s*\(")
|
|
185
|
+
_NOT_JSON_STREAM = re.compile(r"ndjson|jsonl|event-stream|octet-stream", re.I)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _unbalanced(t: str) -> bool:
|
|
189
|
+
depth, in_str, esc = 0, False, False
|
|
190
|
+
for c in t:
|
|
191
|
+
if in_str:
|
|
192
|
+
if esc:
|
|
193
|
+
esc = False
|
|
194
|
+
elif c == "\\":
|
|
195
|
+
esc = True
|
|
196
|
+
elif c == '"':
|
|
197
|
+
in_str = False
|
|
198
|
+
elif c == '"':
|
|
199
|
+
in_str = True
|
|
200
|
+
elif c in "{[":
|
|
201
|
+
depth += 1
|
|
202
|
+
elif c in "}]":
|
|
203
|
+
depth -= 1
|
|
204
|
+
return in_str or depth > 0
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _truthy(v: Any) -> bool:
|
|
208
|
+
return not (v is None or v is False or v == 0 or v == "" or v == [] or v == {})
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _no_const(c: str) -> Any:
|
|
212
|
+
raise ValueError(c)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def expects_json(content_type: str, accept: str, has_schema: bool) -> bool:
|
|
216
|
+
if _NOT_JSON_STREAM.search(content_type):
|
|
217
|
+
return False
|
|
218
|
+
return has_schema or "json" in content_type.lower() or "json" in accept.lower()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def inspect_response(
|
|
222
|
+
status: int,
|
|
223
|
+
content_type: str,
|
|
224
|
+
body: bytes | str,
|
|
225
|
+
schema: Schema | None = None,
|
|
226
|
+
accept: str = "",
|
|
227
|
+
ignore: frozenset[str] | set[str] = frozenset(),
|
|
228
|
+
) -> list[str]:
|
|
229
|
+
"""Return the violations ("rule: detail") of a response; empty when it is fine or not checked."""
|
|
230
|
+
if not 200 <= status < 300 or status in (204, 205):
|
|
231
|
+
return []
|
|
232
|
+
if not expects_json(content_type, accept, schema is not None):
|
|
233
|
+
return []
|
|
234
|
+
text = body.decode("utf-8", "replace") if isinstance(body, bytes) else body
|
|
235
|
+
if text.startswith(""):
|
|
236
|
+
return ["bom: body starts with a byte order mark"]
|
|
237
|
+
t = text.lstrip(_WS)
|
|
238
|
+
if not t:
|
|
239
|
+
return ["empty: 2xx response with no body"]
|
|
240
|
+
ct = content_type.lower()
|
|
241
|
+
if t[0] == "<":
|
|
242
|
+
if t[:5].lower() == "<?xml" or ("xml" in ct and "html" not in ct):
|
|
243
|
+
return ["xml: body is XML, not JSON"]
|
|
244
|
+
return ["html: body is HTML, not JSON"]
|
|
245
|
+
if t.startswith(")]}'"):
|
|
246
|
+
return ["jsonp: XSSI guard prefix before JSON"]
|
|
247
|
+
if _JSONP.match(t):
|
|
248
|
+
return ["jsonp: body is a function call (JSONP), not JSON"]
|
|
249
|
+
try:
|
|
250
|
+
parsed = json.loads(text, parse_constant=_no_const)
|
|
251
|
+
except ValueError:
|
|
252
|
+
if _unbalanced(t) or t.rstrip(_WS)[-1] in ",:":
|
|
253
|
+
return ["truncated: JSON ends before it is complete"]
|
|
254
|
+
return ["invalid_json: body does not parse as JSON"]
|
|
255
|
+
errs: list[str] = []
|
|
256
|
+
declared = schema.declared if schema else set()
|
|
257
|
+
if isinstance(parsed, dict) and "error_envelope" not in ignore:
|
|
258
|
+
for k in ("error", "errors"):
|
|
259
|
+
if k in parsed and k not in declared and _truthy(parsed[k]):
|
|
260
|
+
errs.append(f"error_envelope: 2xx body carries '{k}'")
|
|
261
|
+
for k in ("success", "ok"):
|
|
262
|
+
if parsed.get(k) is False and k not in declared:
|
|
263
|
+
errs.append(f"error_envelope: 2xx body carries {k}=false")
|
|
264
|
+
if schema is not None and "schema" not in ignore:
|
|
265
|
+
errs += schema.validate(parsed, text)
|
|
266
|
+
return errs
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextvars
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import urllib.request
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
from langchain.agents.middleware import AgentMiddleware, hook_config
|
|
12
|
+
from langchain_core.messages import ToolMessage
|
|
13
|
+
|
|
14
|
+
from . import http as _http
|
|
15
|
+
from .core import (
|
|
16
|
+
SAMPLE,
|
|
17
|
+
OpenEntityLoopDetected,
|
|
18
|
+
OpenEntitySchemaViolation,
|
|
19
|
+
Routes,
|
|
20
|
+
Schema,
|
|
21
|
+
compile_schema,
|
|
22
|
+
expects_json,
|
|
23
|
+
inspect_response,
|
|
24
|
+
path_template,
|
|
25
|
+
scrub,
|
|
26
|
+
stable_json,
|
|
27
|
+
)
|
|
28
|
+
from .ledger import LedgerReporter
|
|
29
|
+
|
|
30
|
+
HALT_FLAG = "openentity_halt"
|
|
31
|
+
# Violations raised by guarded HTTP calls inside the running tool. A tool that catches its own exception,
|
|
32
|
+
# or a ToolNode that converts it to a ToolMessage, still cannot hide one from wrap_tool_call.
|
|
33
|
+
_CALL: contextvars.ContextVar[list | None] = contextvars.ContextVar("openentity_call", default=None)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _text(content: Any) -> str:
|
|
37
|
+
if isinstance(content, str):
|
|
38
|
+
return content
|
|
39
|
+
if isinstance(content, list):
|
|
40
|
+
return "".join(b if isinstance(b, str) else b.get("text", "") for b in content if isinstance(b, (str, dict)))
|
|
41
|
+
return json.dumps(content, default=str)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RuthlessSchemaGuard(AgentMiddleware):
|
|
45
|
+
"""Raise on malformed tool output and malformed HTTP responses instead of letting the model guess.
|
|
46
|
+
|
|
47
|
+
Put it first in `middleware=[...]`: the first entry is outermost, so an inner `ToolErrorMiddleware`
|
|
48
|
+
or `ToolRetryMiddleware` cannot turn the violation into a ToolMessage before it is raised.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
schemas: dict[str, Any] | None = None,
|
|
54
|
+
*,
|
|
55
|
+
mode: Literal["raise", "halt"] = "raise",
|
|
56
|
+
max_failures: int = 3,
|
|
57
|
+
ledger: LedgerReporter | str | bool | None = None,
|
|
58
|
+
ignore: tuple[str, ...] = (),
|
|
59
|
+
only_registered: bool = False,
|
|
60
|
+
) -> None:
|
|
61
|
+
super().__init__()
|
|
62
|
+
self.mode, self.max_failures, self.ignore, self.only_registered = mode, max_failures, frozenset(ignore), only_registered
|
|
63
|
+
self.routes = Routes()
|
|
64
|
+
self.tool_schemas: dict[str, Schema] = {}
|
|
65
|
+
# ponytail: one failure counter per guard; call reset() between unrelated runs.
|
|
66
|
+
self._failures: dict[tuple[str, str], int] = {}
|
|
67
|
+
self.last_report: str | None = None
|
|
68
|
+
self.ledger = ledger if isinstance(ledger, LedgerReporter) else LedgerReporter(
|
|
69
|
+
url=ledger if isinstance(ledger, str) else None, enabled=False if ledger is False else None)
|
|
70
|
+
self._uninstall: Callable[[], None] | None = None
|
|
71
|
+
for k, v in (schemas or {}).items():
|
|
72
|
+
self.register(k, v)
|
|
73
|
+
|
|
74
|
+
# ---- registration ----
|
|
75
|
+
def register(self, target: str, schema: Any) -> RuthlessSchemaGuard:
|
|
76
|
+
"""`"GET https://api.example.com/items/*"` registers a route; a bare name registers a tool."""
|
|
77
|
+
s = compile_schema(schema)
|
|
78
|
+
if "://" in target:
|
|
79
|
+
self.routes.add(target, s)
|
|
80
|
+
else:
|
|
81
|
+
self.tool_schemas[target] = s
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def from_openapi(self, spec: dict | str, base_url: str | None = None) -> RuthlessSchemaGuard:
|
|
85
|
+
"""Register every 2xx application/json response of an OpenAPI 3 document (dict, URL or JSON string)."""
|
|
86
|
+
if isinstance(spec, str):
|
|
87
|
+
if spec.lstrip().startswith("{"):
|
|
88
|
+
spec = json.loads(spec)
|
|
89
|
+
else:
|
|
90
|
+
with urllib.request.urlopen(spec, timeout=15) as r:
|
|
91
|
+
spec = json.load(r)
|
|
92
|
+
servers = spec.get("servers") or [{}]
|
|
93
|
+
base = (base_url or servers[0].get("url") or "").rstrip("/")
|
|
94
|
+
if "://" not in base:
|
|
95
|
+
raise ValueError("from_openapi needs base_url when the document has no absolute servers[0].url")
|
|
96
|
+
comps = {"components": spec.get("components", {})}
|
|
97
|
+
for path, item in spec.get("paths", {}).items():
|
|
98
|
+
for method, op in item.items():
|
|
99
|
+
if method.upper() not in ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD") or not isinstance(op, dict):
|
|
100
|
+
continue
|
|
101
|
+
for code, resp in op.get("responses", {}).items():
|
|
102
|
+
if not str(code).startswith("2") or str(code) in ("204", "205"):
|
|
103
|
+
continue
|
|
104
|
+
for ct, media in (resp.get("content") or {}).items():
|
|
105
|
+
if "json" in ct and "schema" in media:
|
|
106
|
+
self.register(f"{method.upper()} {base}{path}", {**comps, **media["schema"]})
|
|
107
|
+
break
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
# ---- transport ----
|
|
111
|
+
def guard_http(self) -> Callable[[], None]:
|
|
112
|
+
"""Guard httpx, httpx2, requests and aiohttp (whichever are installed). Returns the uninstaller."""
|
|
113
|
+
if self._uninstall is None:
|
|
114
|
+
self._uninstall = _http.install(self)
|
|
115
|
+
return self.unguard_http
|
|
116
|
+
|
|
117
|
+
def unguard_http(self) -> None:
|
|
118
|
+
if self._uninstall:
|
|
119
|
+
self._uninstall()
|
|
120
|
+
self._uninstall = None
|
|
121
|
+
|
|
122
|
+
def _wants(self, method: str, url: str, status: int, ct: str, accept: str) -> bool:
|
|
123
|
+
schema = self.routes.match(method, url)
|
|
124
|
+
if self.only_registered and not schema:
|
|
125
|
+
return False
|
|
126
|
+
return 200 <= status < 300 and expects_json(ct, accept, schema is not None)
|
|
127
|
+
|
|
128
|
+
def _check(self, method: str, url: str, status: int, ct: str, accept: str, get_body: Callable[[], bytes]) -> None:
|
|
129
|
+
if not self._wants(method, url, status, ct, accept):
|
|
130
|
+
return
|
|
131
|
+
hit = self.routes.match(method, url)
|
|
132
|
+
schema = hit[0] if hit else None
|
|
133
|
+
body = get_body()
|
|
134
|
+
errs = inspect_response(status, ct, body, schema, accept, self.ignore)
|
|
135
|
+
if not errs:
|
|
136
|
+
return
|
|
137
|
+
sp = urlsplit(url)
|
|
138
|
+
text = body.decode("utf-8", "replace") if isinstance(body, bytes) else body
|
|
139
|
+
self.ledger.report({
|
|
140
|
+
"host": sp.hostname or "", "method": method.upper(),
|
|
141
|
+
"path_template": hit[1] if hit else path_template(url),
|
|
142
|
+
"expected_schema_hash": schema.hash if schema else "",
|
|
143
|
+
"status": status, "content_type": ct,
|
|
144
|
+
"body_sha256": hashlib.sha256(body if isinstance(body, bytes) else body.encode()).hexdigest(),
|
|
145
|
+
"sample": scrub(text)[:SAMPLE],
|
|
146
|
+
})
|
|
147
|
+
exc = OpenEntitySchemaViolation(f"{method.upper()} {sp.scheme}://{sp.netloc}{sp.path}", errs, text)
|
|
148
|
+
if (seen := _CALL.get()) is not None:
|
|
149
|
+
seen.append(exc)
|
|
150
|
+
raise exc
|
|
151
|
+
|
|
152
|
+
# ---- tools ----
|
|
153
|
+
def reset(self) -> None:
|
|
154
|
+
self._failures.clear()
|
|
155
|
+
|
|
156
|
+
def _key(self, request: Any) -> tuple[str, tuple[str, str]]:
|
|
157
|
+
"""Failures are counted per thread, so a guard shared by concurrent runs does not mix them."""
|
|
158
|
+
tc = request.tool_call
|
|
159
|
+
cfg = getattr(getattr(request, "runtime", None), "config", None) or {}
|
|
160
|
+
thread = str((cfg.get("configurable") or {}).get("thread_id", ""))
|
|
161
|
+
return tc["name"], (thread, hashlib.sha256(f"{tc['name']}\0{stable_json(tc.get('args', {}))}".encode()).hexdigest())
|
|
162
|
+
|
|
163
|
+
def _count(self, name: str, key: tuple[str, str]) -> OpenEntityLoopDetected | None:
|
|
164
|
+
self._failures[key] = n = self._failures.get(key, 0) + 1
|
|
165
|
+
return OpenEntityLoopDetected(name, key[1], n) if n >= self.max_failures else None
|
|
166
|
+
|
|
167
|
+
def _validate(self, name: str, result: Any) -> None:
|
|
168
|
+
schema = self.tool_schemas.get(name)
|
|
169
|
+
if schema is None or not isinstance(result, ToolMessage) or result.status == "error":
|
|
170
|
+
return
|
|
171
|
+
text = _text(result.content)
|
|
172
|
+
errs = inspect_response(200, "application/json", text, schema, "", self.ignore)
|
|
173
|
+
if errs:
|
|
174
|
+
raise OpenEntitySchemaViolation(name, errs, text)
|
|
175
|
+
|
|
176
|
+
def _halted(self, request: Any, exc: BaseException) -> ToolMessage:
|
|
177
|
+
errs = getattr(exc, "errors", None) or [str(exc)]
|
|
178
|
+
self.last_report = f"RuthlessSchemaGuard halted the run: {exc.__class__.__name__} in {request.tool_call['name']}: " + "; ".join(errs[:5])
|
|
179
|
+
return ToolMessage(self.last_report, tool_call_id=request.tool_call["id"], name=request.tool_call["name"],
|
|
180
|
+
status="error", additional_kwargs={HALT_FLAG: True})
|
|
181
|
+
|
|
182
|
+
def _outcome(self, request: Any, run: Callable[[], Any], seen: list) -> Any:
|
|
183
|
+
name, key = self._key(request)
|
|
184
|
+
try:
|
|
185
|
+
result = run()
|
|
186
|
+
if seen:
|
|
187
|
+
raise seen[0]
|
|
188
|
+
self._validate(name, result)
|
|
189
|
+
except (OpenEntitySchemaViolation, OpenEntityLoopDetected) as e:
|
|
190
|
+
return self._exit(request, self._count(name, key) or e)
|
|
191
|
+
except Exception as e: # noqa: BLE001 - counted; re-raised unchanged until the limit
|
|
192
|
+
loop = self._count(name, key)
|
|
193
|
+
if loop is None:
|
|
194
|
+
raise
|
|
195
|
+
return self._exit(request, loop)
|
|
196
|
+
if isinstance(result, ToolMessage) and result.status == "error":
|
|
197
|
+
if loop := self._count(name, key):
|
|
198
|
+
return self._exit(request, loop)
|
|
199
|
+
return result
|
|
200
|
+
self._failures.pop(key, None)
|
|
201
|
+
return result
|
|
202
|
+
|
|
203
|
+
def _exit(self, request: Any, exc: BaseException) -> ToolMessage:
|
|
204
|
+
if self.mode == "halt":
|
|
205
|
+
return self._halted(request, exc)
|
|
206
|
+
raise exc
|
|
207
|
+
|
|
208
|
+
def wrap_tool_call(self, request: Any, handler: Callable[[Any], Any]) -> Any:
|
|
209
|
+
seen: list = []
|
|
210
|
+
token = _CALL.set(seen)
|
|
211
|
+
try:
|
|
212
|
+
return self._outcome(request, lambda: handler(request), seen)
|
|
213
|
+
finally:
|
|
214
|
+
_CALL.reset(token)
|
|
215
|
+
|
|
216
|
+
async def awrap_tool_call(self, request: Any, handler: Callable[[Any], Awaitable[Any]]) -> Any:
|
|
217
|
+
# Await first, then reuse the sync logic on the outcome.
|
|
218
|
+
seen: list = []
|
|
219
|
+
token = _CALL.set(seen)
|
|
220
|
+
try:
|
|
221
|
+
result = await handler(request)
|
|
222
|
+
except Exception as e: # noqa: BLE001
|
|
223
|
+
def fail() -> Any:
|
|
224
|
+
raise e
|
|
225
|
+
return self._outcome(request, fail, seen)
|
|
226
|
+
finally:
|
|
227
|
+
_CALL.reset(token)
|
|
228
|
+
return self._outcome(request, lambda: result, seen)
|
|
229
|
+
|
|
230
|
+
# ---- halt mode ----
|
|
231
|
+
@hook_config(can_jump_to=["end"])
|
|
232
|
+
def before_model(self, state: Any, runtime: Any) -> dict[str, Any] | None:
|
|
233
|
+
last = state["messages"][-1] if state.get("messages") else None
|
|
234
|
+
if isinstance(last, ToolMessage) and last.additional_kwargs.get(HALT_FLAG):
|
|
235
|
+
return {"jump_to": "end"}
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
@hook_config(can_jump_to=["end"])
|
|
239
|
+
async def abefore_model(self, state: Any, runtime: Any) -> dict[str, Any] | None:
|
|
240
|
+
return self.before_model(state, runtime)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Patch the send path of httpx, httpx2 (Open-SWE uses it), requests and aiohttp, when installed.
|
|
2
|
+
|
|
3
|
+
Streaming responses (`stream=True`, SSE) are never read, so they are never checked.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import functools
|
|
9
|
+
import importlib
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _patch(obj: Any, name: str, make: Callable[[Callable], Callable], undo: list) -> None:
|
|
14
|
+
orig = getattr(obj, name)
|
|
15
|
+
setattr(obj, name, functools.wraps(orig)(make(orig)))
|
|
16
|
+
undo.append(lambda: setattr(obj, name, orig))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def install(guard: Any) -> Callable[[], None]:
|
|
20
|
+
undo: list[Callable[[], None]] = []
|
|
21
|
+
|
|
22
|
+
def httpx_like(mod: Any) -> None:
|
|
23
|
+
def sync(orig):
|
|
24
|
+
def send(self, request, *a, **kw):
|
|
25
|
+
resp = orig(self, request, *a, **kw)
|
|
26
|
+
if not kw.get("stream"):
|
|
27
|
+
r = resp.request
|
|
28
|
+
guard._check(r.method, str(r.url), resp.status_code, resp.headers.get("content-type", ""),
|
|
29
|
+
r.headers.get("accept", ""), lambda: resp.content)
|
|
30
|
+
return resp
|
|
31
|
+
return send
|
|
32
|
+
|
|
33
|
+
def asyn(orig):
|
|
34
|
+
async def send(self, request, *a, **kw):
|
|
35
|
+
resp = await orig(self, request, *a, **kw)
|
|
36
|
+
if not kw.get("stream"):
|
|
37
|
+
r = resp.request
|
|
38
|
+
guard._check(r.method, str(r.url), resp.status_code, resp.headers.get("content-type", ""),
|
|
39
|
+
r.headers.get("accept", ""), lambda: resp.content)
|
|
40
|
+
return resp
|
|
41
|
+
return send
|
|
42
|
+
|
|
43
|
+
_patch(mod.Client, "send", sync, undo)
|
|
44
|
+
_patch(mod.AsyncClient, "send", asyn, undo)
|
|
45
|
+
|
|
46
|
+
def requests_(mod: Any) -> None:
|
|
47
|
+
def make(orig):
|
|
48
|
+
def send(self, request, **kw):
|
|
49
|
+
resp = orig(self, request, **kw)
|
|
50
|
+
if not kw.get("stream"):
|
|
51
|
+
guard._check(request.method, resp.url, resp.status_code, resp.headers.get("content-type", ""),
|
|
52
|
+
request.headers.get("Accept", ""), lambda: resp.content)
|
|
53
|
+
return resp
|
|
54
|
+
return send
|
|
55
|
+
|
|
56
|
+
_patch(mod.Session, "send", make, undo)
|
|
57
|
+
|
|
58
|
+
def aiohttp_(mod: Any) -> None:
|
|
59
|
+
def make(orig):
|
|
60
|
+
async def _request(self, method, str_or_url, **kw):
|
|
61
|
+
resp = await orig(self, method, str_or_url, **kw)
|
|
62
|
+
ct = resp.headers.get("content-type", "")
|
|
63
|
+
accept = (kw.get("headers") or {}).get("Accept", "")
|
|
64
|
+
if guard._wants(method, str(resp.url), resp.status, ct, accept):
|
|
65
|
+
body = await resp.read()
|
|
66
|
+
guard._check(method, str(resp.url), resp.status, ct, accept, lambda: body)
|
|
67
|
+
return resp
|
|
68
|
+
return _request
|
|
69
|
+
|
|
70
|
+
_patch(mod.ClientSession, "_request", make, undo)
|
|
71
|
+
|
|
72
|
+
for name, fn in (("httpx", httpx_like), ("httpx2", httpx_like), ("requests", requests_), ("aiohttp", aiohttp_)):
|
|
73
|
+
try:
|
|
74
|
+
fn(importlib.import_module(name))
|
|
75
|
+
except ImportError:
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
def uninstall() -> None:
|
|
79
|
+
while undo:
|
|
80
|
+
undo.pop()()
|
|
81
|
+
|
|
82
|
+
return uninstall
|