nullprotocol 0.2.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.
- nullprotocol/__init__.py +29 -0
- nullprotocol/_agent.py +518 -0
- nullprotocol/_client.py +108 -0
- nullprotocol/_common.py +292 -0
- nullprotocol/_errors.py +43 -0
- nullprotocol/_knowledge.py +163 -0
- nullprotocol/_stores.py +189 -0
- nullprotocol/_transport.py +276 -0
- nullprotocol/_types.py +175 -0
- nullprotocol/py.typed +0 -0
- nullprotocol-0.2.0.dist-info/METADATA +268 -0
- nullprotocol-0.2.0.dist-info/RECORD +14 -0
- nullprotocol-0.2.0.dist-info/WHEEL +4 -0
- nullprotocol-0.2.0.dist-info/licenses/LICENSE +21 -0
nullprotocol/_common.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from threading import Lock
|
|
8
|
+
from typing import Any, TypeVar
|
|
9
|
+
from uuid import UUID, uuid4
|
|
10
|
+
from weakref import WeakKeyDictionary, WeakValueDictionary
|
|
11
|
+
|
|
12
|
+
from jsonschema import Draft202012Validator, FormatChecker
|
|
13
|
+
|
|
14
|
+
from ._transport import identifier, json_body, request_id, scope_id, utf16_length
|
|
15
|
+
from ._types import Result
|
|
16
|
+
|
|
17
|
+
Knowledge = bool | dict[str, str]
|
|
18
|
+
ActionChoice = str | dict[str, str]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def object_field(body: dict[str, Any], field: str) -> dict[str, Any]:
|
|
22
|
+
from ._errors import ProtocolError
|
|
23
|
+
|
|
24
|
+
value = body.get(field)
|
|
25
|
+
if not isinstance(value, dict):
|
|
26
|
+
raise ProtocolError(f"Missing {field} in the server response.")
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def list_field(body: dict[str, Any], field: str) -> list[dict[str, Any]]:
|
|
31
|
+
from ._errors import ProtocolError
|
|
32
|
+
|
|
33
|
+
value = body.get(field)
|
|
34
|
+
if not isinstance(value, list) or not all(isinstance(row, dict) for row in value):
|
|
35
|
+
raise ProtocolError(f"Missing {field} in the server response.")
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def version(value: str) -> str:
|
|
40
|
+
try:
|
|
41
|
+
UUID(value)
|
|
42
|
+
except (ValueError, TypeError, AttributeError) as exc:
|
|
43
|
+
raise ValueError("Expected a UUID version or note ID.") from exc
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def checked_text(value: str, name: str, maximum: int) -> str:
|
|
48
|
+
if not isinstance(value, str) or not value.strip() or utf16_length(value) > maximum:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"{name} must be a nonempty string of at most {maximum} UTF-16 characters."
|
|
51
|
+
)
|
|
52
|
+
if re.search(r"[\x00-\x1f\x7f-\x9f]", value):
|
|
53
|
+
raise ValueError(f"{name} must not contain control characters.")
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def operation_payload(
|
|
58
|
+
operation: str,
|
|
59
|
+
values: dict[str, Any],
|
|
60
|
+
scope: str | None,
|
|
61
|
+
knowledge: Knowledge | None,
|
|
62
|
+
context_keys: list[str] | None,
|
|
63
|
+
) -> dict[str, Any]:
|
|
64
|
+
body = {"operation": operation, **values}
|
|
65
|
+
if scope is not None:
|
|
66
|
+
body["conversation"] = scope
|
|
67
|
+
if knowledge is not None:
|
|
68
|
+
if type(knowledge) is bool:
|
|
69
|
+
body["knowledge"] = knowledge
|
|
70
|
+
elif isinstance(knowledge, dict) and set(knowledge) == {"query"}:
|
|
71
|
+
query = knowledge["query"]
|
|
72
|
+
if (
|
|
73
|
+
not isinstance(query, str)
|
|
74
|
+
or not query.strip()
|
|
75
|
+
or utf16_length(query.strip()) > 2000
|
|
76
|
+
):
|
|
77
|
+
raise ValueError(
|
|
78
|
+
"Knowledge query must have 1–2000 UTF-16 characters after trimming."
|
|
79
|
+
)
|
|
80
|
+
# The server returns knowledge_query_too_large if the byte limit is exceeded.
|
|
81
|
+
body["knowledge"] = {"query": query.strip()}
|
|
82
|
+
else:
|
|
83
|
+
raise ValueError("knowledge must be a bool or {'query': '...'}.")
|
|
84
|
+
if context_keys is not None:
|
|
85
|
+
if not isinstance(context_keys, list) or len(context_keys) > 16:
|
|
86
|
+
raise ValueError("context_keys must be a list of at most 16 keys.")
|
|
87
|
+
body["contextKeys"] = [identifier(key, "context key") for key in context_keys]
|
|
88
|
+
if len(json_body(body)) > 131072:
|
|
89
|
+
raise ValueError("The HTTP operation body exceeds 131072 UTF-8 bytes.")
|
|
90
|
+
return body
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def summary_options(content: Any, max_length: int, focus: str | None) -> dict[str, Any]:
|
|
94
|
+
if type(max_length) is not int or not 1 <= max_length <= 10000:
|
|
95
|
+
raise ValueError("max_length must be an integer from 1 to 10000.")
|
|
96
|
+
body = {"content": content, "maxLength": max_length}
|
|
97
|
+
if focus is not None:
|
|
98
|
+
if not isinstance(focus, str) or utf16_length(focus) > 1000:
|
|
99
|
+
raise ValueError("focus must have at most 1000 UTF-16 characters.")
|
|
100
|
+
body["focus"] = focus
|
|
101
|
+
return body
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def validation_options(
|
|
105
|
+
criteria: str, subject: Any, reference: Any, unset: object
|
|
106
|
+
) -> dict[str, Any]:
|
|
107
|
+
if not isinstance(criteria, str) or not 1 <= utf16_length(criteria) <= 16384:
|
|
108
|
+
raise ValueError("criteria must have 1–16384 UTF-16 characters.")
|
|
109
|
+
body = {"criteria": criteria, "subject": subject}
|
|
110
|
+
if reference is not unset:
|
|
111
|
+
body["reference"] = reference
|
|
112
|
+
return body
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def schema_validator(schema: dict[str, Any]) -> Draft202012Validator:
|
|
116
|
+
if not isinstance(schema, dict):
|
|
117
|
+
raise ValueError("input_schema must be a JSON Schema object.")
|
|
118
|
+
Draft202012Validator.check_schema(schema)
|
|
119
|
+
checker = FormatChecker()
|
|
120
|
+
|
|
121
|
+
def check(value: Any) -> None:
|
|
122
|
+
if not isinstance(value, dict):
|
|
123
|
+
return
|
|
124
|
+
for ref in ("$ref", "$dynamicRef", "$recursiveRef"):
|
|
125
|
+
if ref in value and not str(value[ref]).startswith("#"):
|
|
126
|
+
raise ValueError(
|
|
127
|
+
"Action schemas must use local references; remote refs are unsupported."
|
|
128
|
+
)
|
|
129
|
+
if "format" in value and value["format"] not in checker.checkers:
|
|
130
|
+
raise ValueError(f"Unsupported action schema format: {value['format']}")
|
|
131
|
+
# Descend through schema positions only. A property literally named
|
|
132
|
+
# format/$ref, an enum value or a default is ordinary application data.
|
|
133
|
+
for keyword in (
|
|
134
|
+
"properties",
|
|
135
|
+
"patternProperties",
|
|
136
|
+
"$defs",
|
|
137
|
+
"definitions",
|
|
138
|
+
"dependentSchemas",
|
|
139
|
+
):
|
|
140
|
+
for child in value.get(keyword, {}).values():
|
|
141
|
+
check(child)
|
|
142
|
+
for keyword in ("allOf", "anyOf", "oneOf", "prefixItems"):
|
|
143
|
+
for child in value.get(keyword, []):
|
|
144
|
+
check(child)
|
|
145
|
+
for keyword in (
|
|
146
|
+
"items",
|
|
147
|
+
"additionalItems",
|
|
148
|
+
"additionalProperties",
|
|
149
|
+
"unevaluatedProperties",
|
|
150
|
+
"unevaluatedItems",
|
|
151
|
+
"contains",
|
|
152
|
+
"propertyNames",
|
|
153
|
+
"if",
|
|
154
|
+
"then",
|
|
155
|
+
"else",
|
|
156
|
+
"not",
|
|
157
|
+
"contentSchema",
|
|
158
|
+
):
|
|
159
|
+
child = value.get(keyword)
|
|
160
|
+
if isinstance(child, list):
|
|
161
|
+
for item in child:
|
|
162
|
+
check(item)
|
|
163
|
+
else:
|
|
164
|
+
check(child)
|
|
165
|
+
for child in value.get("dependencies", {}).values():
|
|
166
|
+
if isinstance(child, dict):
|
|
167
|
+
check(child)
|
|
168
|
+
|
|
169
|
+
check(schema)
|
|
170
|
+
return Draft202012Validator(deepcopy(schema), format_checker=checker)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class RegisteredAction:
|
|
175
|
+
handler: Callable[..., Any]
|
|
176
|
+
description: str | None
|
|
177
|
+
schema: dict[str, Any] | None
|
|
178
|
+
validator: Draft202012Validator | None
|
|
179
|
+
guard: Callable[..., Any] | None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass
|
|
183
|
+
class MadeDecision:
|
|
184
|
+
snapshot: Result[Any]
|
|
185
|
+
input: Any
|
|
186
|
+
executed: bool = False
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
_AgentT = TypeVar("_AgentT", bound="AgentState")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class AgentState:
|
|
193
|
+
def __init__(self, agent_id: str, scope: str | None) -> None:
|
|
194
|
+
self.agent_id = identifier(agent_id, "agent ID")
|
|
195
|
+
self.scope = scope_id(scope)
|
|
196
|
+
self._actions: dict[str, RegisteredAction] = {}
|
|
197
|
+
self._decisions: WeakKeyDictionary[Result[Any], MadeDecision] = WeakKeyDictionary()
|
|
198
|
+
self._decision_ids: WeakValueDictionary[str, MadeDecision] = WeakValueDictionary()
|
|
199
|
+
self._lock = Lock()
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def _path(self) -> str:
|
|
203
|
+
return f"/agents/{self.agent_id}"
|
|
204
|
+
|
|
205
|
+
def register_action(
|
|
206
|
+
self: _AgentT,
|
|
207
|
+
name: str,
|
|
208
|
+
handler: Callable[..., Any],
|
|
209
|
+
*,
|
|
210
|
+
description: str | None = None,
|
|
211
|
+
input_schema: dict[str, Any] | None = None,
|
|
212
|
+
guard: Callable[..., Any] | None = None,
|
|
213
|
+
) -> _AgentT:
|
|
214
|
+
checked_text(name, "Action name", 64)
|
|
215
|
+
if not callable(handler) or (guard is not None and not callable(guard)):
|
|
216
|
+
raise ValueError("handler and guard must be callable.")
|
|
217
|
+
if description is not None and (
|
|
218
|
+
not isinstance(description, str) or utf16_length(description) > 1000
|
|
219
|
+
):
|
|
220
|
+
raise ValueError("Action description must have at most 1000 UTF-16 characters.")
|
|
221
|
+
validator = schema_validator(input_schema) if input_schema is not None else None
|
|
222
|
+
self._actions[name] = RegisteredAction(
|
|
223
|
+
handler,
|
|
224
|
+
description,
|
|
225
|
+
deepcopy(input_schema),
|
|
226
|
+
validator,
|
|
227
|
+
guard,
|
|
228
|
+
)
|
|
229
|
+
return self
|
|
230
|
+
|
|
231
|
+
def _choices(self, context: Any, actions: list[ActionChoice]) -> dict[str, Any]:
|
|
232
|
+
if not isinstance(actions, list) or not 1 <= len(actions) <= 32:
|
|
233
|
+
raise ValueError("actions must contain 1–32 action choices.")
|
|
234
|
+
choices, schemas = [], {}
|
|
235
|
+
for offered in actions:
|
|
236
|
+
if isinstance(offered, str):
|
|
237
|
+
name = offered
|
|
238
|
+
description = None
|
|
239
|
+
elif isinstance(offered, dict) and set(offered) <= {"action", "description"}:
|
|
240
|
+
name = offered.get("action", "")
|
|
241
|
+
description = offered.get("description")
|
|
242
|
+
else:
|
|
243
|
+
raise ValueError(
|
|
244
|
+
"Use action names or {'action': name, 'description': text} choices."
|
|
245
|
+
)
|
|
246
|
+
checked_text(name, "Action name", 64)
|
|
247
|
+
registered = self._actions.get(name)
|
|
248
|
+
if description is None and registered is not None:
|
|
249
|
+
description = registered.description
|
|
250
|
+
choice = {"action": name}
|
|
251
|
+
if description is not None:
|
|
252
|
+
if not isinstance(description, str) or utf16_length(description) > 1000:
|
|
253
|
+
raise ValueError("Action description must have at most 1000 UTF-16 characters.")
|
|
254
|
+
choice["description"] = description
|
|
255
|
+
choices.append(choice)
|
|
256
|
+
if registered is not None and registered.schema is not None:
|
|
257
|
+
schemas[name] = deepcopy(registered.schema)
|
|
258
|
+
# The HTTP endpoint accepts action descriptions, not schemas. Send schema
|
|
259
|
+
# contracts as structured task data and enforce them locally before execution.
|
|
260
|
+
prepared = {"task": context, "actionParameterSchemas": schemas} if schemas else context
|
|
261
|
+
return {"context": prepared, "actions": choices}
|
|
262
|
+
|
|
263
|
+
def _remember(self, result: Result[Any], original: Any) -> Result[Any]:
|
|
264
|
+
if result.success:
|
|
265
|
+
snapshot = Result.from_response(
|
|
266
|
+
{"result": result.raw, "operationId": result.operation_id},
|
|
267
|
+
result.request_id,
|
|
268
|
+
)
|
|
269
|
+
with self._lock:
|
|
270
|
+
key = result.operation_id or "request:" + result.request_id
|
|
271
|
+
made = self._decision_ids.get(key)
|
|
272
|
+
if made is None:
|
|
273
|
+
made = MadeDecision(snapshot, deepcopy(original))
|
|
274
|
+
self._decision_ids[key] = made
|
|
275
|
+
self._decisions[result] = made
|
|
276
|
+
return result
|
|
277
|
+
|
|
278
|
+
def _consume(self, result: Result[Any]) -> tuple[MadeDecision | None, str | None]:
|
|
279
|
+
if not isinstance(result, Result):
|
|
280
|
+
return None, "not_a_decision"
|
|
281
|
+
with self._lock:
|
|
282
|
+
made = self._decisions.get(result)
|
|
283
|
+
if made is None:
|
|
284
|
+
return None, "not_a_decision"
|
|
285
|
+
if made.executed:
|
|
286
|
+
return made, "already_executed"
|
|
287
|
+
made.executed = True
|
|
288
|
+
return made, None
|
|
289
|
+
|
|
290
|
+
@staticmethod
|
|
291
|
+
def _task_id(value: str | None) -> str:
|
|
292
|
+
return request_id(str(uuid4()) if value is None else value)
|
nullprotocol/_errors.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NullProtocolError(Exception):
|
|
7
|
+
"""Base SDK error. Failed model operations are returned as Result, not raised."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class APIError(NullProtocolError):
|
|
11
|
+
"""An HTTP failure. Keep request_id when retrying an uncertain run."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
status_code: int,
|
|
16
|
+
code: str,
|
|
17
|
+
*,
|
|
18
|
+
request_id: str | None = None,
|
|
19
|
+
details: dict[str, Any] | None = None,
|
|
20
|
+
retry_after: str | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
self.status_code = status_code
|
|
23
|
+
self.code = code
|
|
24
|
+
self.request_id = request_id
|
|
25
|
+
self.details = details or {}
|
|
26
|
+
self.retry_after = retry_after
|
|
27
|
+
super().__init__(f"NullProtocol HTTP {status_code}: {code}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TransportError(NullProtocolError):
|
|
31
|
+
"""The response was not received. A run may have completed: retain request_id."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, message: str, *, request_id: str | None = None) -> None:
|
|
34
|
+
self.request_id = request_id
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ProtocolError(NullProtocolError):
|
|
39
|
+
"""The server response does not match the expected response contract."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, message: str, *, request_id: str | None = None) -> None:
|
|
42
|
+
self.request_id = request_id
|
|
43
|
+
super().__init__(message)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ._common import checked_text, list_field, object_field, version
|
|
6
|
+
from ._transport import AsyncTransport, Transport
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _fields(
|
|
10
|
+
name: str | None, text: str | None, *, update: bool = False, if_version: str | None = None
|
|
11
|
+
) -> dict[str, Any]:
|
|
12
|
+
result: dict[str, Any] = {}
|
|
13
|
+
if name is not None or not update:
|
|
14
|
+
if not isinstance(name, str):
|
|
15
|
+
raise ValueError("Knowledge name must be text.")
|
|
16
|
+
result["name"] = checked_text(name.strip(), "Knowledge name", 120)
|
|
17
|
+
if text is not None or not update:
|
|
18
|
+
if not isinstance(text, str) or not text.strip() or "\0" in text:
|
|
19
|
+
raise ValueError("Knowledge text must be nonempty UTF-8 text without NUL characters.")
|
|
20
|
+
if len(text.encode("utf-8")) > 1048576:
|
|
21
|
+
raise ValueError("A Knowledge document holds up to 1 MiB of UTF-8 text.")
|
|
22
|
+
result["text"] = text
|
|
23
|
+
if update:
|
|
24
|
+
if not result:
|
|
25
|
+
raise ValueError("Supply a new name or text to update a Knowledge document.")
|
|
26
|
+
if not isinstance(if_version, str):
|
|
27
|
+
raise ValueError("if_version is required; read the document's current version first.")
|
|
28
|
+
result["ifVersion"] = version(if_version)
|
|
29
|
+
return result
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _Documents:
|
|
33
|
+
def __init__(self, transport: Transport, path: str, scope: str) -> None:
|
|
34
|
+
self._transport, self._path, self._scope = transport, path, scope
|
|
35
|
+
|
|
36
|
+
def _all(self) -> list[dict[str, Any]]:
|
|
37
|
+
return list_field(self._transport.request("GET", self._path), "sources")
|
|
38
|
+
|
|
39
|
+
def list(self) -> list[dict[str, Any]]:
|
|
40
|
+
"""List documents owned by this scope. Connections are listed separately."""
|
|
41
|
+
return [source for source in self._all() if source.get("scope") == self._scope]
|
|
42
|
+
|
|
43
|
+
def get(self, source_id: str) -> dict[str, Any]:
|
|
44
|
+
return object_field(
|
|
45
|
+
self._transport.request("GET", self._path + "/" + version(source_id)), "source"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def add(self, *, name: str, text: str) -> dict[str, Any]:
|
|
49
|
+
"""Save text and begin preparation. Inspect get(id)['state'] for readiness."""
|
|
50
|
+
return self._transport.request("POST", self._path, body=_fields(name, text))
|
|
51
|
+
|
|
52
|
+
def update(
|
|
53
|
+
self, source_id: str, *, if_version: str, name: str | None = None, text: str | None = None
|
|
54
|
+
) -> dict[str, Any]:
|
|
55
|
+
return self._transport.request(
|
|
56
|
+
"PUT",
|
|
57
|
+
self._path + "/" + version(source_id),
|
|
58
|
+
body=_fields(name, text, update=True, if_version=if_version),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def delete(self, source_id: str, *, if_version: str) -> None:
|
|
62
|
+
self._transport.request(
|
|
63
|
+
"DELETE", self._path + "/" + version(source_id), body={"ifVersion": version(if_version)}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def retry(self, source_id: str) -> dict[str, Any]:
|
|
67
|
+
"""Explicitly retry failed preparation; this can make embedding requests."""
|
|
68
|
+
return self._transport.request(
|
|
69
|
+
"POST", self._path + "/" + version(source_id) + "/retry", body={}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class KnowledgeLibrary(_Documents):
|
|
74
|
+
"""The Space's shared library. Adding a source does not connect it to an agent."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, transport: Transport) -> None:
|
|
77
|
+
super().__init__(transport, "/knowledge", "space")
|
|
78
|
+
|
|
79
|
+
def usage(self) -> dict[str, Any]:
|
|
80
|
+
"""Embedding usage over 30 days; unknown sums remain None, known sums are strings."""
|
|
81
|
+
return object_field(self._transport.request("GET", self._path), "usage")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class AgentKnowledge(_Documents):
|
|
85
|
+
def __init__(self, transport: Transport, agent_id: str) -> None:
|
|
86
|
+
super().__init__(transport, f"/agents/{agent_id}/knowledge", "agent")
|
|
87
|
+
self._links = f"/agents/{agent_id}/knowledge-links"
|
|
88
|
+
|
|
89
|
+
def connections(self) -> list[dict[str, Any]]:
|
|
90
|
+
return [s for s in self._all() if s.get("scope") == "space" and s.get("attached")]
|
|
91
|
+
|
|
92
|
+
def connect(self, space_source_id: str) -> dict[str, Any]:
|
|
93
|
+
return self._transport.request("PUT", self._links + "/" + version(space_source_id), body={})
|
|
94
|
+
|
|
95
|
+
def disconnect(self, space_source_id: str) -> dict[str, Any]:
|
|
96
|
+
return self._transport.request(
|
|
97
|
+
"DELETE", self._links + "/" + version(space_source_id), body={}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _AsyncDocuments:
|
|
102
|
+
def __init__(self, transport: AsyncTransport, path: str, scope: str) -> None:
|
|
103
|
+
self._transport, self._path, self._scope = transport, path, scope
|
|
104
|
+
|
|
105
|
+
async def _all(self) -> list[dict[str, Any]]:
|
|
106
|
+
return list_field(await self._transport.request("GET", self._path), "sources")
|
|
107
|
+
|
|
108
|
+
async def list(self) -> list[dict[str, Any]]:
|
|
109
|
+
return [source for source in await self._all() if source.get("scope") == self._scope]
|
|
110
|
+
|
|
111
|
+
async def get(self, source_id: str) -> dict[str, Any]:
|
|
112
|
+
return object_field(
|
|
113
|
+
await self._transport.request("GET", self._path + "/" + version(source_id)), "source"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
async def add(self, *, name: str, text: str) -> dict[str, Any]:
|
|
117
|
+
return await self._transport.request("POST", self._path, body=_fields(name, text))
|
|
118
|
+
|
|
119
|
+
async def update(
|
|
120
|
+
self, source_id: str, *, if_version: str, name: str | None = None, text: str | None = None
|
|
121
|
+
) -> dict[str, Any]:
|
|
122
|
+
return await self._transport.request(
|
|
123
|
+
"PUT",
|
|
124
|
+
self._path + "/" + version(source_id),
|
|
125
|
+
body=_fields(name, text, update=True, if_version=if_version),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
async def delete(self, source_id: str, *, if_version: str) -> None:
|
|
129
|
+
await self._transport.request(
|
|
130
|
+
"DELETE", self._path + "/" + version(source_id), body={"ifVersion": version(if_version)}
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
async def retry(self, source_id: str) -> dict[str, Any]:
|
|
134
|
+
return await self._transport.request(
|
|
135
|
+
"POST", self._path + "/" + version(source_id) + "/retry", body={}
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class AsyncKnowledgeLibrary(_AsyncDocuments):
|
|
140
|
+
def __init__(self, transport: AsyncTransport) -> None:
|
|
141
|
+
super().__init__(transport, "/knowledge", "space")
|
|
142
|
+
|
|
143
|
+
async def usage(self) -> dict[str, Any]:
|
|
144
|
+
return object_field(await self._transport.request("GET", self._path), "usage")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class AsyncAgentKnowledge(_AsyncDocuments):
|
|
148
|
+
def __init__(self, transport: AsyncTransport, agent_id: str) -> None:
|
|
149
|
+
super().__init__(transport, f"/agents/{agent_id}/knowledge", "agent")
|
|
150
|
+
self._links = f"/agents/{agent_id}/knowledge-links"
|
|
151
|
+
|
|
152
|
+
async def connections(self) -> list[dict[str, Any]]:
|
|
153
|
+
return [s for s in await self._all() if s.get("scope") == "space" and s.get("attached")]
|
|
154
|
+
|
|
155
|
+
async def connect(self, space_source_id: str) -> dict[str, Any]:
|
|
156
|
+
return await self._transport.request(
|
|
157
|
+
"PUT", self._links + "/" + version(space_source_id), body={}
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
async def disconnect(self, space_source_id: str) -> dict[str, Any]:
|
|
161
|
+
return await self._transport.request(
|
|
162
|
+
"DELETE", self._links + "/" + version(space_source_id), body={}
|
|
163
|
+
)
|
nullprotocol/_stores.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ._common import list_field, object_field, version
|
|
6
|
+
from ._knowledge import AsyncKnowledgeLibrary, KnowledgeLibrary
|
|
7
|
+
from ._transport import _UNSET, AsyncTransport, Transport, identifier
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _context_body(value: Any, inclusion: str | None, if_version: Any) -> dict[str, Any]:
|
|
11
|
+
body = {"value": value}
|
|
12
|
+
if inclusion is not None:
|
|
13
|
+
if inclusion not in ("always", "selected"):
|
|
14
|
+
raise ValueError("inclusion must be 'always' or 'selected'.")
|
|
15
|
+
body["inclusion"] = inclusion
|
|
16
|
+
if if_version is not _UNSET:
|
|
17
|
+
body["ifVersion"] = None if if_version is None else version(if_version)
|
|
18
|
+
return body
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ContextStore:
|
|
22
|
+
def __init__(self, transport: Transport, path: str, scope: str | None = None) -> None:
|
|
23
|
+
self._transport, self._path = transport, path
|
|
24
|
+
self._params = {"conversation": scope} if scope is not None else None
|
|
25
|
+
|
|
26
|
+
def list(self) -> list[dict[str, Any]]:
|
|
27
|
+
return list_field(
|
|
28
|
+
self._transport.request("GET", self._path, params=self._params), "entries"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def get(self, key: str) -> dict[str, Any]:
|
|
32
|
+
return object_field(
|
|
33
|
+
self._transport.request(
|
|
34
|
+
"GET",
|
|
35
|
+
self._path + "/" + identifier(key, "context key"),
|
|
36
|
+
params=self._params,
|
|
37
|
+
),
|
|
38
|
+
"entry",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def set(
|
|
42
|
+
self,
|
|
43
|
+
key: str,
|
|
44
|
+
value: Any,
|
|
45
|
+
*,
|
|
46
|
+
inclusion: str | None = None,
|
|
47
|
+
if_version: Any = _UNSET,
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
return object_field(
|
|
50
|
+
self._transport.request(
|
|
51
|
+
"PUT",
|
|
52
|
+
self._path + "/" + identifier(key, "context key"),
|
|
53
|
+
params=self._params,
|
|
54
|
+
body=_context_body(value, inclusion, if_version),
|
|
55
|
+
),
|
|
56
|
+
"entry",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def delete(self, key: str) -> None:
|
|
60
|
+
self._transport.request(
|
|
61
|
+
"DELETE",
|
|
62
|
+
self._path + "/" + identifier(key, "context key"),
|
|
63
|
+
params=self._params,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AsyncContextStore:
|
|
68
|
+
def __init__(self, transport: AsyncTransport, path: str, scope: str | None = None) -> None:
|
|
69
|
+
self._transport, self._path = transport, path
|
|
70
|
+
self._params = {"conversation": scope} if scope is not None else None
|
|
71
|
+
|
|
72
|
+
async def list(self) -> list[dict[str, Any]]:
|
|
73
|
+
return list_field(
|
|
74
|
+
await self._transport.request(
|
|
75
|
+
"GET",
|
|
76
|
+
self._path,
|
|
77
|
+
params=self._params,
|
|
78
|
+
),
|
|
79
|
+
"entries",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
async def get(self, key: str) -> dict[str, Any]:
|
|
83
|
+
return object_field(
|
|
84
|
+
await self._transport.request(
|
|
85
|
+
"GET",
|
|
86
|
+
self._path + "/" + identifier(key, "context key"),
|
|
87
|
+
params=self._params,
|
|
88
|
+
),
|
|
89
|
+
"entry",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
async def set(
|
|
93
|
+
self,
|
|
94
|
+
key: str,
|
|
95
|
+
value: Any,
|
|
96
|
+
*,
|
|
97
|
+
inclusion: str | None = None,
|
|
98
|
+
if_version: Any = _UNSET,
|
|
99
|
+
) -> dict[str, Any]:
|
|
100
|
+
return object_field(
|
|
101
|
+
await self._transport.request(
|
|
102
|
+
"PUT",
|
|
103
|
+
self._path + "/" + identifier(key, "context key"),
|
|
104
|
+
params=self._params,
|
|
105
|
+
body=_context_body(value, inclusion, if_version),
|
|
106
|
+
),
|
|
107
|
+
"entry",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
async def delete(self, key: str) -> None:
|
|
111
|
+
await self._transport.request(
|
|
112
|
+
"DELETE",
|
|
113
|
+
self._path + "/" + identifier(key, "context key"),
|
|
114
|
+
params=self._params,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _note(text: str, scope: str | None) -> dict[str, Any]:
|
|
119
|
+
if not isinstance(text, str) or not text.strip() or len(text.strip().encode()) > 4096:
|
|
120
|
+
raise ValueError("A note must be nonempty text of at most 4096 UTF-8 bytes.")
|
|
121
|
+
return {"text": text, **({"conversation": scope} if scope is not None else {})}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class MemoryStore:
|
|
125
|
+
def __init__(self, transport: Transport, path: str, scope: str | None) -> None:
|
|
126
|
+
self._transport, self._path, self._scope = transport, path, scope
|
|
127
|
+
|
|
128
|
+
def list(self) -> list[dict[str, Any]]:
|
|
129
|
+
return list_field(
|
|
130
|
+
self._transport.request(
|
|
131
|
+
"GET",
|
|
132
|
+
self._path,
|
|
133
|
+
params={"conversation": self._scope} if self._scope else None,
|
|
134
|
+
),
|
|
135
|
+
"notes",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def add(self, text: str) -> dict[str, Any]:
|
|
139
|
+
return object_field(
|
|
140
|
+
self._transport.request(
|
|
141
|
+
"POST",
|
|
142
|
+
self._path,
|
|
143
|
+
body=_note(text, self._scope),
|
|
144
|
+
),
|
|
145
|
+
"note",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def delete(self, note_id: str) -> None:
|
|
149
|
+
self._transport.request("DELETE", self._path + "/" + version(note_id))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class AsyncMemoryStore:
|
|
153
|
+
def __init__(self, transport: AsyncTransport, path: str, scope: str | None) -> None:
|
|
154
|
+
self._transport, self._path, self._scope = transport, path, scope
|
|
155
|
+
|
|
156
|
+
async def list(self) -> list[dict[str, Any]]:
|
|
157
|
+
return list_field(
|
|
158
|
+
await self._transport.request(
|
|
159
|
+
"GET",
|
|
160
|
+
self._path,
|
|
161
|
+
params={"conversation": self._scope} if self._scope else None,
|
|
162
|
+
),
|
|
163
|
+
"notes",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
async def add(self, text: str) -> dict[str, Any]:
|
|
167
|
+
return object_field(
|
|
168
|
+
await self._transport.request(
|
|
169
|
+
"POST",
|
|
170
|
+
self._path,
|
|
171
|
+
body=_note(text, self._scope),
|
|
172
|
+
),
|
|
173
|
+
"note",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
async def delete(self, note_id: str) -> None:
|
|
177
|
+
await self._transport.request("DELETE", self._path + "/" + version(note_id))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class SpaceContextStore(ContextStore):
|
|
181
|
+
def __init__(self, transport: Transport) -> None:
|
|
182
|
+
super().__init__(transport, "/context")
|
|
183
|
+
self.knowledge = KnowledgeLibrary(transport)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class AsyncSpaceContextStore(AsyncContextStore):
|
|
187
|
+
def __init__(self, transport: AsyncTransport) -> None:
|
|
188
|
+
super().__init__(transport, "/context")
|
|
189
|
+
self.knowledge = AsyncKnowledgeLibrary(transport)
|