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.
- composio_typesafe/__init__.py +99 -0
- composio_typesafe/classify.py +229 -0
- composio_typesafe/companion.py +307 -0
- composio_typesafe/compile.py +287 -0
- composio_typesafe/decide.py +650 -0
- composio_typesafe/keys.py +97 -0
- composio_typesafe/provider.py +346 -0
- composio_typesafe/py.typed +0 -0
- composio_typesafe/response.py +179 -0
- composio_typesafe/types.py +504 -0
- composio_typesafe-0.22.0.dist-info/METADATA +272 -0
- composio_typesafe-0.22.0.dist-info/RECORD +14 -0
- composio_typesafe-0.22.0.dist-info/WHEEL +5 -0
- composio_typesafe-0.22.0.dist-info/top_level.txt +1 -0
|
@@ -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
|