composio-typesafe 0.22.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,99 @@
1
+ from composio_typesafe.decide import (
2
+ DEFAULT_MODEL,
3
+ DEFAULT_THRESHOLDS,
4
+ DESTRUCTIVE_ROUTING_THRESHOLD,
5
+ MAX_TOOLS,
6
+ REQUEST_BUDGET_TOKENS,
7
+ )
8
+ from composio_typesafe.provider import TypesafeProvider
9
+ from composio_typesafe.types import (
10
+ TypesafeAbstainDecision,
11
+ TypesafeAbstainedDecisionError,
12
+ TypesafeApiError,
13
+ TypesafeApiErrorReason,
14
+ TypesafeArgumentQuestion,
15
+ TypesafeArrayArgument,
16
+ TypesafeBypassInfo,
17
+ TypesafeCallDecision,
18
+ TypesafeCandidate,
19
+ TypesafeChoiceArgument,
20
+ TypesafeChoiceQuestion,
21
+ TypesafeClientLike,
22
+ TypesafeConfirmationRequiredError,
23
+ TypesafeContextScope,
24
+ TypesafeDecision,
25
+ TypesafeDecisionMeta,
26
+ TypesafeDuplicateToolError,
27
+ TypesafeGateBlockedError,
28
+ TypesafeGateContext,
29
+ TypesafeGateUnavailableError,
30
+ TypesafeGateVetoError,
31
+ TypesafeIncompleteDecisionError,
32
+ TypesafeInvalidOptionsError,
33
+ TypesafeJudgement,
34
+ TypesafeLimitError,
35
+ TypesafeMalformedDecisionError,
36
+ TypesafeMalformedResponseError,
37
+ TypesafeMissingApiKeyError,
38
+ TypesafeNoulQuestion,
39
+ TypesafeOption,
40
+ TypesafePartialDecision,
41
+ TypesafeProviderError,
42
+ TypesafeQuestion,
43
+ TypesafeRequestState,
44
+ TypesafeShortlist,
45
+ TypesafeState,
46
+ TypesafeThresholds,
47
+ TypesafeToolQuestions,
48
+ TypesafeToolSet,
49
+ parse_decision,
50
+ )
51
+
52
+ __all__ = (
53
+ "DEFAULT_MODEL",
54
+ "DEFAULT_THRESHOLDS",
55
+ "DESTRUCTIVE_ROUTING_THRESHOLD",
56
+ "MAX_TOOLS",
57
+ "REQUEST_BUDGET_TOKENS",
58
+ "TypesafeAbstainDecision",
59
+ "TypesafeAbstainedDecisionError",
60
+ "TypesafeApiError",
61
+ "TypesafeApiErrorReason",
62
+ "TypesafeArgumentQuestion",
63
+ "TypesafeArrayArgument",
64
+ "TypesafeBypassInfo",
65
+ "TypesafeCallDecision",
66
+ "TypesafeCandidate",
67
+ "TypesafeChoiceArgument",
68
+ "TypesafeChoiceQuestion",
69
+ "TypesafeClientLike",
70
+ "TypesafeConfirmationRequiredError",
71
+ "TypesafeContextScope",
72
+ "TypesafeDecision",
73
+ "TypesafeDecisionMeta",
74
+ "TypesafeDuplicateToolError",
75
+ "TypesafeGateBlockedError",
76
+ "TypesafeGateContext",
77
+ "TypesafeGateUnavailableError",
78
+ "TypesafeGateVetoError",
79
+ "TypesafeIncompleteDecisionError",
80
+ "TypesafeInvalidOptionsError",
81
+ "TypesafeJudgement",
82
+ "TypesafeLimitError",
83
+ "TypesafeMalformedDecisionError",
84
+ "TypesafeMalformedResponseError",
85
+ "TypesafeMissingApiKeyError",
86
+ "TypesafeNoulQuestion",
87
+ "TypesafeOption",
88
+ "TypesafePartialDecision",
89
+ "TypesafeProvider",
90
+ "TypesafeProviderError",
91
+ "TypesafeQuestion",
92
+ "TypesafeRequestState",
93
+ "TypesafeShortlist",
94
+ "TypesafeState",
95
+ "TypesafeThresholds",
96
+ "TypesafeToolQuestions",
97
+ "TypesafeToolSet",
98
+ "parse_decision",
99
+ )
@@ -0,0 +1,229 @@
1
+ """Classifies one tool argument as a closed set Jev can bind, or as open-ended."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typing as t
6
+
7
+ import typing_extensions as te
8
+ from pydantic import (
9
+ BaseModel,
10
+ BeforeValidator,
11
+ ConfigDict,
12
+ StrictBool,
13
+ StrictStr,
14
+ model_validator,
15
+ )
16
+ from pydantic import ValidationError as PydanticValidationError
17
+
18
+ from .keys import MAX_CHOICE_OPTIONS
19
+ from .types import _json_number
20
+
21
+ _MAX_SAFE_INTEGER = 2**53 - 1
22
+
23
+ _JsonSchemaType = t.Literal[
24
+ "string", "number", "integer", "boolean", "object", "array", "null"
25
+ ]
26
+ # A JSON number: an integer on the wire is as valid as a float. `bool` is an `int`
27
+ # subclass, so `_json_number` rejects it, and strings never reach the float.
28
+ _Number = te.Annotated[float, BeforeValidator(_json_number)]
29
+
30
+
31
+ class JsonSchemaProperty(BaseModel):
32
+ """
33
+ The keywords of a JSON Schema property that classification reads, typed as the
34
+ TypeScript SDK types them (`JSONSchemaPropertySchema` in `@composio/core`). A property
35
+ that fails this parse is open-ended.
36
+ """
37
+
38
+ model_config = ConfigDict(extra="ignore")
39
+
40
+ type: t.Optional[t.Union[_JsonSchemaType, t.List[_JsonSchemaType]]] = None
41
+ description: t.Optional[StrictStr] = None
42
+ anyOf: t.Optional[t.List["JsonSchemaProperty"]] = None
43
+ oneOf: t.Optional[t.List["JsonSchemaProperty"]] = None
44
+ nullable: t.Optional[StrictBool] = None
45
+ items: t.Optional[t.Union["JsonSchemaProperty", t.List["JsonSchemaProperty"]]] = (
46
+ None
47
+ )
48
+ enum: t.Optional[t.List[t.Any]] = None
49
+ const: t.Any = None
50
+ maxItems: t.Optional[_Number] = None
51
+ minItems: t.Optional[_Number] = None
52
+
53
+ @model_validator(mode="before")
54
+ @classmethod
55
+ def _reject_null(cls, data: t.Any) -> t.Any:
56
+ # A keyword may be absent, but only `const` may be JSON `null`.
57
+ if isinstance(data, dict):
58
+ for keyword, value in data.items():
59
+ if value is None and keyword in _NON_NULLABLE_KEYWORDS:
60
+ raise ValueError("null keyword")
61
+ return data
62
+
63
+
64
+ _NON_NULLABLE_KEYWORDS = frozenset(
65
+ field.alias or name
66
+ for name, field in JsonSchemaProperty.model_fields.items()
67
+ if name != "const"
68
+ )
69
+
70
+
71
+ def parse_property(schema: t.Any) -> t.Optional[t.Dict[str, t.Any]]:
72
+ """Returns the property when it has the accepted shape, else `None`."""
73
+ if not isinstance(schema, dict):
74
+ return None
75
+ try:
76
+ JsonSchemaProperty.model_validate(schema)
77
+ except (PydanticValidationError, RecursionError):
78
+ return None
79
+ return schema
80
+
81
+
82
+ class EnumClass(te.TypedDict):
83
+ kind: t.Literal["enum"]
84
+ values: t.List[t.Union[str, int]]
85
+ nullable: bool
86
+
87
+
88
+ class BooleanClass(te.TypedDict):
89
+ kind: t.Literal["boolean"]
90
+ nullable: bool
91
+
92
+
93
+ class EnumArrayClass(te.TypedDict):
94
+ kind: t.Literal["enum_array"]
95
+ values: t.List[t.Union[str, int]]
96
+ maxItems: te.NotRequired[t.Union[int, float]]
97
+ minItems: te.NotRequired[t.Union[int, float]]
98
+
99
+
100
+ class OpenClass(te.TypedDict):
101
+ kind: t.Literal["open"]
102
+
103
+
104
+ ArgumentClass: te.TypeAlias = t.Union[
105
+ EnumClass, BooleanClass, EnumArrayClass, OpenClass
106
+ ]
107
+
108
+ _OPEN: OpenClass = {"kind": "open"}
109
+
110
+
111
+ def _types_of(schema: t.Mapping[str, t.Any]) -> t.List[str]:
112
+ declared = schema.get("type")
113
+ if declared is None:
114
+ return []
115
+ return list(declared) if isinstance(declared, list) else [declared]
116
+
117
+
118
+ def _literal_members(schema: t.Mapping[str, t.Any]) -> t.Optional[t.List[t.Any]]:
119
+ """
120
+ Collects the literal members a property allows, or `None` when any branch is free-form.
121
+ `anyOf`/`oneOf` branches must each be a `const`, an `enum`, or `{"type": "null"}`.
122
+ """
123
+ if "enum" in schema:
124
+ return list(schema["enum"])
125
+ if "const" in schema:
126
+ return [schema["const"]]
127
+ branches = schema["anyOf"] if "anyOf" in schema else schema.get("oneOf")
128
+ if not branches:
129
+ return None
130
+ members: t.List[t.Any] = []
131
+ for branch in branches:
132
+ if _types_of(branch) == ["null"] and "enum" not in branch:
133
+ members.append(None)
134
+ continue
135
+ if "enum" not in branch and "const" not in branch:
136
+ return None
137
+ nested = _literal_members(branch)
138
+ if nested is None:
139
+ return None
140
+ members.extend(nested)
141
+ return members
142
+
143
+
144
+ def _normalize_number(member: t.Any) -> t.Any:
145
+ # JSON `1.0` is the integer 1 in JavaScript. `bool` is an `int` subclass, so it is excluded.
146
+ if isinstance(member, float) and member.is_integer():
147
+ return int(member)
148
+ return member
149
+
150
+
151
+ def _classify_members(members: t.List[t.Any], nullable_hint: bool) -> ArgumentClass:
152
+ nullable = nullable_hint or any(member is None for member in members)
153
+ values: t.List[t.Any] = []
154
+ seen: t.Set[t.Tuple[str, t.Any]] = set()
155
+ for member in members:
156
+ if member is None:
157
+ continue
158
+ value = _normalize_number(member)
159
+ # `1`, `True`, and `"1"` are three different members.
160
+ identity = (
161
+ (type(value).__name__, value)
162
+ if isinstance(value, (bool, int, float, str))
163
+ else ("object", id(member))
164
+ )
165
+ if identity not in seen:
166
+ seen.add(identity)
167
+ values.append(value)
168
+ if not values:
169
+ return _OPEN
170
+ if all(isinstance(value, bool) for value in values):
171
+ # The boolean Choice offers both values, so a set that allows only one is open-ended.
172
+ if len(values) != 2:
173
+ return _OPEN
174
+ return {"kind": "boolean", "nullable": nullable}
175
+ strings = [value for value in values if isinstance(value, str)]
176
+ integers = [
177
+ value
178
+ for value in values
179
+ if isinstance(value, int)
180
+ and not isinstance(value, bool)
181
+ and abs(value) <= _MAX_SAFE_INTEGER
182
+ ]
183
+ # Mixed-type enums are open-ended: `1` and `"1"` cannot both be told apart by Jev.
184
+ homogeneous: t.List[t.Union[str, int]] = (
185
+ list(strings) if len(strings) == len(values) else list(integers)
186
+ )
187
+ if len(homogeneous) != len(values):
188
+ return _OPEN
189
+ # One slot is taken by "not stated", and one more by `null` when nullable.
190
+ if len(homogeneous) + (2 if nullable else 1) > MAX_CHOICE_OPTIONS:
191
+ return _OPEN
192
+ return {"kind": "enum", "values": homogeneous, "nullable": nullable}
193
+
194
+
195
+ def classify_property(parsed: t.Mapping[str, t.Any]) -> ArgumentClass:
196
+ """
197
+ Classifies one dereferenced property that `parse_property` accepted. Anything that is
198
+ not a closed set is open-ended.
199
+ """
200
+ types = _types_of(parsed)
201
+ nullable_hint = parsed.get("nullable") is True or "null" in types
202
+ value_types = [declared for declared in types if declared != "null"]
203
+
204
+ if "object" in value_types:
205
+ return _OPEN
206
+
207
+ if "array" in value_types:
208
+ items = parsed.get("items")
209
+ if len(value_types) != 1 or not isinstance(items, dict):
210
+ return _OPEN
211
+ item_class = classify_property(items)
212
+ if item_class["kind"] != "enum" or item_class["nullable"]:
213
+ return _OPEN
214
+ array_class: EnumArrayClass = {
215
+ "kind": "enum_array",
216
+ "values": item_class["values"],
217
+ }
218
+ if "maxItems" in parsed:
219
+ array_class["maxItems"] = _normalize_number(parsed["maxItems"])
220
+ if "minItems" in parsed:
221
+ array_class["minItems"] = _normalize_number(parsed["minItems"])
222
+ return array_class
223
+
224
+ members = _literal_members(parsed)
225
+ if members is not None:
226
+ return _classify_members(members, nullable_hint)
227
+ if value_types == ["boolean"]:
228
+ return {"kind": "boolean", "nullable": nullable_hint}
229
+ return _OPEN
@@ -0,0 +1,307 @@
1
+ """Companion helpers: `shortlist_tools` and `confidence_gate`. Both work with any provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import math
7
+ import threading
8
+ import typing as t
9
+
10
+ from composio.client.types import Tool
11
+ from composio.core.models._modifiers import ToolExecuteParams
12
+
13
+ from .compile import ROUTING_QUESTION_ID, routing_description_of, routing_question
14
+ from .decide import (
15
+ MAX_TOOLS,
16
+ REQUEST_BUDGET_TOKENS,
17
+ Ask,
18
+ Plan,
19
+ Questions,
20
+ estimate_tokens,
21
+ normalize_state,
22
+ stable,
23
+ )
24
+ from .types import (
25
+ TypesafeApiError,
26
+ TypesafeAvailabilityReason,
27
+ TypesafeBypassInfo,
28
+ TypesafeGateBlockedError,
29
+ TypesafeGateContext,
30
+ TypesafeGateUnavailableError,
31
+ TypesafeGateVetoError,
32
+ TypesafeInvalidOptionsError,
33
+ TypesafeJsonValue,
34
+ TypesafeLimitError,
35
+ TypesafeMalformedResponseError,
36
+ TypesafeNoulQuestion,
37
+ TypesafeProviderError,
38
+ TypesafeShortlist,
39
+ TypesafeState,
40
+ )
41
+
42
+
43
+ def _is_integer(value: t.Any) -> bool:
44
+ return isinstance(value, int) and not isinstance(value, bool)
45
+
46
+
47
+ def plan_shortlist(
48
+ tools: t.Sequence[Tool],
49
+ state: TypesafeState,
50
+ k: int,
51
+ ) -> Plan[TypesafeShortlist]:
52
+ """Ranks raw tools against a state. Routing sees `request` only, never `context`."""
53
+ if not _is_integer(k) or k < 0:
54
+ raise TypesafeInvalidOptionsError("`k` must be a non-negative integer.")
55
+ if len(tools) > MAX_TOOLS:
56
+ raise TypesafeLimitError("tools")
57
+ normalized = normalize_state(state)
58
+ if k == 0 or len(tools) == 0 or len(normalized.request.strip()) == 0:
59
+ return {"tools": [], "scores": []}
60
+
61
+ # Routing needs the slug and the routing text only, so the argument schemas stay unread.
62
+ routing = routing_question(
63
+ [
64
+ {"slug": tool.slug, "routingDescription": routing_description_of(tool)}
65
+ for tool in tools
66
+ ]
67
+ )
68
+ questions: Questions = {ROUTING_QUESTION_ID: routing["question"]}
69
+ if estimate_tokens(normalized.request_only, questions) > REQUEST_BUDGET_TOKENS:
70
+ raise TypesafeLimitError("request_budget")
71
+ answers = yield (normalized.request_only, questions)
72
+ route = answers.choices.get(ROUTING_QUESTION_ID)
73
+ if route is None:
74
+ raise TypesafeMalformedResponseError("missing_answer")
75
+
76
+ ranked = sorted(
77
+ (
78
+ (-route.probabilities.get(routing["keys"][index], 0.0), index)
79
+ for index in range(len(tools))
80
+ )
81
+ )[:k]
82
+ return {
83
+ "tools": [tools[index] for _, index in ranked],
84
+ "scores": [
85
+ {"slug": tools[index].slug, "score": -negated} for negated, index in ranked
86
+ ],
87
+ }
88
+
89
+
90
+ GATE_QUESTION_ID = "gate"
91
+
92
+ # Static text. The proposed call travels in state under `proposed_call`, so text an LLM
93
+ # wrote into the arguments never sits inside the question.
94
+ GATE_QUESTION: TypesafeNoulQuestion = {
95
+ "type": "noul",
96
+ "instructions": "Does running the tool in `proposed_call` with those arguments "
97
+ "carry out what `request` asks for?",
98
+ "criteria": {
99
+ "true": "The proposed call does what the user asked for in `request`.",
100
+ "false": "The proposed call does something the user did not ask for, or "
101
+ "contradicts `request`.",
102
+ },
103
+ }
104
+
105
+
106
+ _AVAILABILITY_REASONS: t.Tuple[TypesafeAvailabilityReason, ...] = (
107
+ "timeout",
108
+ "connection",
109
+ "server_error",
110
+ "rate_limit",
111
+ )
112
+
113
+
114
+ class GateModifier(t.Protocol):
115
+ def __call__(
116
+ self, tool: str, toolkit: str, params: ToolExecuteParams
117
+ ) -> ToolExecuteParams: ...
118
+
119
+
120
+ def _stable_or_block(value: t.Any) -> TypesafeJsonValue:
121
+ """A value that is not JSON blocks the call. Nothing is dropped or rewritten."""
122
+ try:
123
+ return stable(value)
124
+ except TypesafeInvalidOptionsError:
125
+ pass
126
+ # Raised outside the `except` block, so the invalid-options error is not the context.
127
+ raise TypesafeGateBlockedError("check_failed")
128
+
129
+
130
+ def _leaf_kind(value: t.Any) -> str:
131
+ # JSON scalar kinds. `bool` is checked first, because it is an `int` subclass.
132
+ if isinstance(value, bool):
133
+ return "boolean"
134
+ if isinstance(value, (int, float)):
135
+ return "number"
136
+ if isinstance(value, str):
137
+ return "string"
138
+ if isinstance(value, (dict, list)):
139
+ # Containers are compared structurally by the recursion, never by kind, so a
140
+ # container at a scalar position (e.g. a redactor replacing a JSON null with
141
+ # an object) cannot pass as a null-to-null mask.
142
+ return "container"
143
+ return "null"
144
+
145
+
146
+ def _assert_masking_only(original: t.Any, redacted: t.Any) -> None:
147
+ """
148
+ The redactor is a masker: the redacted arguments must have exactly the original's
149
+ JSON structure, meaning the same key sets and the same array lengths, and every leaf
150
+ replacement must keep the same scalar kind. Anything else blocks, because Jev would
151
+ approve a call that differs from the one that runs.
152
+ """
153
+ if isinstance(original, list):
154
+ if not isinstance(redacted, list) or len(redacted) != len(original):
155
+ raise TypesafeGateBlockedError("check_failed")
156
+ for entry, replacement in zip(original, redacted):
157
+ _assert_masking_only(entry, replacement)
158
+ return
159
+ if isinstance(original, dict):
160
+ if not isinstance(redacted, dict) or set(redacted) != set(original):
161
+ raise TypesafeGateBlockedError("check_failed")
162
+ for key, entry in original.items():
163
+ _assert_masking_only(entry, redacted[key])
164
+ return
165
+ if _leaf_kind(original) != _leaf_kind(redacted):
166
+ raise TypesafeGateBlockedError("check_failed")
167
+
168
+
169
+ def confidence_gate(
170
+ *,
171
+ ask: Ask,
172
+ tools: t.Sequence[Tool],
173
+ get_request: t.Callable[[TypesafeGateContext], str],
174
+ get_context: t.Optional[
175
+ t.Callable[[TypesafeGateContext], TypesafeJsonValue]
176
+ ] = None,
177
+ threshold: float = 0.7,
178
+ on_unavailable: t.Literal["block", "allow"] = "block",
179
+ on_bypass: t.Optional[t.Callable[[TypesafeBypassInfo], None]] = None,
180
+ max_vetoes: int = 3,
181
+ redact_arguments: t.Optional[
182
+ t.Callable[[str, t.Dict[str, t.Any]], t.Dict[str, t.Any]]
183
+ ] = None,
184
+ ) -> GateModifier:
185
+ """
186
+ Builds a `before_execute` callable that asks Jev whether a proposed tool call matches
187
+ the user's request. Create one gate per agent run and reuse it across that run's retries.
188
+ """
189
+ if (
190
+ isinstance(threshold, bool)
191
+ or not isinstance(threshold, (int, float))
192
+ or math.isnan(threshold)
193
+ or not 0 <= threshold <= 1
194
+ ):
195
+ raise TypesafeInvalidOptionsError(
196
+ "The gate threshold must be a number from 0 to 1."
197
+ )
198
+ if not _is_integer(max_vetoes) or max_vetoes < 1:
199
+ raise TypesafeInvalidOptionsError("`max_vetoes` must be a positive integer.")
200
+ if on_unavailable not in ("block", "allow"):
201
+ # A typo'd mode must never fail open at check time.
202
+ raise TypesafeInvalidOptionsError(
203
+ "`on_unavailable` must be 'block' or 'allow'."
204
+ )
205
+ by_slug = {tool.slug: tool for tool in tools}
206
+ lock = threading.Lock()
207
+ vetoes = 0
208
+
209
+ def check(tool: str, toolkit: str, params: ToolExecuteParams) -> ToolExecuteParams:
210
+ nonlocal vetoes
211
+ # A hijacked LLM must not be able to vary arguments until one call passes.
212
+ if vetoes >= max_vetoes:
213
+ raise TypesafeGateBlockedError("max_vetoes")
214
+ raw_tool = by_slug.get(tool)
215
+ if raw_tool is None:
216
+ raise TypesafeGateBlockedError("unknown_tool")
217
+
218
+ context: TypesafeGateContext = {
219
+ "tool_slug": tool,
220
+ "toolkit_slug": toolkit,
221
+ "params": params,
222
+ }
223
+ request = get_request(context)
224
+ gate_context = None if get_context is None else get_context(context)
225
+ call_arguments: t.Any = dict(params.get("arguments") or {})
226
+ if redact_arguments is not None:
227
+ # The redactor works on a deep copy, so one that edits in place cannot alter
228
+ # the call that runs.
229
+ copied: t.Any = None
230
+ try:
231
+ copied = copy.deepcopy(call_arguments)
232
+ except Exception:
233
+ pass
234
+ if copied is None:
235
+ raise TypesafeGateBlockedError("check_failed")
236
+ # The originals are checked first: what the gate reads must be JSON in full.
237
+ original_arguments = _stable_or_block(call_arguments)
238
+ call_arguments = redact_arguments(raw_tool.slug, copied)
239
+ if not isinstance(call_arguments, dict) or not all(
240
+ isinstance(key, str) for key in call_arguments
241
+ ):
242
+ raise TypesafeGateBlockedError("check_failed")
243
+ # Jev evaluates what is sent, and the original arguments run. The redactor
244
+ # may only mask, so an approved call is exactly the call that executes.
245
+ _assert_masking_only(original_arguments, _stable_or_block(call_arguments))
246
+ description = getattr(raw_tool, "description", None)
247
+ # Only the slug and the arguments are copied out of the execution parameters.
248
+ # `user_id`, `connected_account_id`, and the custom auth fields never leave.
249
+ payload: t.Dict[str, t.Any] = {
250
+ "request": request,
251
+ "proposed_call": {
252
+ "tool": raw_tool.slug,
253
+ "description": raw_tool.name if description is None else description,
254
+ "arguments": call_arguments,
255
+ },
256
+ }
257
+ if gate_context is not None:
258
+ payload["context"] = gate_context
259
+ state: TypesafeJsonValue = None
260
+ try:
261
+ state = stable(payload)
262
+ except TypesafeInvalidOptionsError:
263
+ pass
264
+ # A call the gate cannot read in full is blocked, never sent with a value dropped.
265
+ if state is None:
266
+ raise TypesafeGateBlockedError("check_failed")
267
+ questions: Questions = {GATE_QUESTION_ID: GATE_QUESTION}
268
+ # Arguments are never truncated to fit.
269
+ if estimate_tokens(state, questions) > REQUEST_BUDGET_TOKENS:
270
+ raise TypesafeGateBlockedError("oversized_call")
271
+
272
+ probability: t.Optional[float] = None
273
+ failure: t.Optional[TypesafeProviderError] = None
274
+ try:
275
+ answer = ask(state, questions).nouls.get(GATE_QUESTION_ID)
276
+ probability = None if answer is None else answer.noul
277
+ except TypesafeProviderError as error:
278
+ failure = error
279
+ if failure is not None or probability is None:
280
+ reason = next(
281
+ (
282
+ known
283
+ for known in _AVAILABILITY_REASONS
284
+ if isinstance(failure, TypesafeApiError) and failure.reason == known
285
+ ),
286
+ None,
287
+ )
288
+ if reason is None:
289
+ raise TypesafeGateBlockedError("check_failed")
290
+ if on_unavailable == "block":
291
+ raise TypesafeGateUnavailableError(reason)
292
+ if on_bypass is not None:
293
+ on_bypass({"tool_slug": raw_tool.slug, "reason": reason})
294
+ return params
295
+
296
+ if probability >= threshold:
297
+ return params
298
+ vetoes += 1
299
+ raise TypesafeGateVetoError(probability, threshold)
300
+
301
+ def gate(tool: str, toolkit: str, params: ToolExecuteParams) -> ToolExecuteParams:
302
+ # The lock spans the whole check, request included. Checks on one gate run one at
303
+ # a time, so a burst of parallel calls cannot get past the veto budget together.
304
+ with lock:
305
+ return check(tool, toolkit, params)
306
+
307
+ return gate