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.
@@ -0,0 +1,14 @@
1
+ node_modules/
2
+ dist/
3
+ .astro/
4
+ .wrangler/
5
+ .dev.vars
6
+ .env*
7
+ !.env.example
8
+ __pycache__/
9
+ *.pyc
10
+ .venv/
11
+ *.egg-info/
12
+ .DS_Store
13
+ coverage/
14
+ .claude/
@@ -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